//! Tribler metainfo (file-list) fetch + parsing for the metainfo fan-out //! (PR2 step 3). Turns one torrent infohash into the list of files it contains, //! so `compute_outcomes` can emit one `btih-v1-file` CID per video file plus a //! whole-torrent pack CID. //! //! Source: Tribler core `POST /api/torrentinfo/uri` with a JSON body //! `{"uri": ""}` (the `uri` query param alone 500s — the handler reads //! the body as JSON). Response shape (`GetMetainfoResponse`): //! //! ```json //! { "files": [ {"index": 0, "name": "Show.S01E01.mkv", "size": 123}, … ], //! "name": "Show Complete Season 1", "description": "", … } //! ``` //! //! The DHT metainfo resolve is **slow and may fail** (no peers / no metadata); //! callers treat `None` as "couldn't enumerate, fall back to the single-file //! record" — a fan-out failure must never fail the resolve. use serde::{Deserialize, Serialize}; /// One file inside a torrent. `index` is the zero-based position in the info /// dict's file list — i.e. the `btih-v1-file` CID's file index. Serialized into /// the SDK `filelist` redb cache so fan-out survives across `compute_outcomes` /// calls without re-hitting the DHT. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TorrentFile { pub index: usize, /// File path/name inside the torrent (forward-slash joined). pub name: String, /// File length in bytes. pub size: u64, } /// Video file extensions we fan out to (lowercase, no dot). Matches the torznab /// feeder's `VIDEO_EXTENSIONS` so both feeders agree on what "a video file" is. const VIDEO_EXTENSIONS: &[&str] = &[ "mkv", "mp4", "avi", "m4v", "mov", "webm", "mpg", "mpeg", "wmv", "flv", "ogv", "ts", "mts", "m2ts", ]; /// True if `name`'s extension is a known video container. pub fn looks_like_video(name: &str) -> bool { name.rsplit('.') .next() .map(|ext| { let lower = ext.to_ascii_lowercase(); VIDEO_EXTENSIONS.iter().any(|e| *e == lower) }) .unwrap_or(false) } /// Tribler `GetMetainfoResponse.files[]` entry. Tolerant field naming: Tribler /// uses `name`/`size`; we also accept `path`/`length` in case a tag differs. #[derive(Debug, Default, Deserialize)] struct MiniFileInfo { #[serde(default)] index: Option, #[serde(default)] name: Option, #[serde(default)] path: Option, #[serde(default)] size: Option, #[serde(default)] length: Option, } #[derive(Debug, Default, Deserialize)] struct MetainfoResponse { #[serde(default)] files: Vec, } /// Parse a Tribler `GetMetainfoResponse` body into [`TorrentFile`]s, filling a /// fallback `index` from array position when absent. pub fn parse_metainfo(body: &str) -> Vec { let Ok(resp) = serde_json::from_str::(body) else { return Vec::new(); }; resp.files .into_iter() .enumerate() .filter_map(|(pos, f)| { let name = f.name.or(f.path)?; if name.trim().is_empty() { return None; } Some(TorrentFile { index: f.index.unwrap_or(pos), name, size: f.size.or(f.length).unwrap_or(0), }) }) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn looks_like_video_matches_extensions() { assert!(looks_like_video("Show.S01E01.1080p.mkv")); assert!(looks_like_video("movie.MP4")); assert!(!looks_like_video("readme.txt")); assert!(!looks_like_video("Pressrelease.pdf")); assert!(!looks_like_video("noext")); } #[test] fn parse_metainfo_real_tribler_shape() { // Captured from the live Tribler `/api/torrentinfo/uri` response. let body = r#"{"files":[ {"index":0,"name":"Big_Buck_Bunny_1080p.avi","size":928670754}, {"index":1,"name":"PROMOTE.txt","size":5008}, {"index":2,"name":"Pressrelease.pdf","size":3456234}, {"index":3,"name":"license.txt","size":180} ],"name":"Big Buck Bunny","description":""}"#; let files = parse_metainfo(body); assert_eq!(files.len(), 4); assert_eq!(files[0].index, 0); assert_eq!(files[0].name, "Big_Buck_Bunny_1080p.avi"); assert_eq!(files[0].size, 928670754); let videos: Vec<_> = files.iter().filter(|f| looks_like_video(&f.name)).collect(); assert_eq!(videos.len(), 1, "only the .avi is video"); } #[test] fn parse_metainfo_index_fallback_to_position() { let body = r#"{"files":[{"name":"a.mkv","size":1},{"name":"b.mkv","size":2}]}"#; let files = parse_metainfo(body); assert_eq!(files[0].index, 0); assert_eq!(files[1].index, 1); } #[test] fn parse_metainfo_empty_or_garbage() { assert!(parse_metainfo("not json").is_empty()); assert!(parse_metainfo(r#"{"files":[]}"#).is_empty()); } }