diff --git a/src/peertube.rs b/src/peertube.rs index fe52900..12f4028 100644 --- a/src/peertube.rs +++ b/src/peertube.rs @@ -99,9 +99,158 @@ impl PeerTubeClient { } } +/// Absolutify a PeerTube relative path (`/lazy-static/…`) against the API base. +fn absolutify(api_base: &str, path: &str) -> String { + if path.starts_with("http://") || path.starts_with("https://") { + path.to_string() + } else { + format!("{api_base}{path}") + } +} + +fn map_channel(v: &Value, api_base: &str) -> Option { + let name = v.get("name").and_then(Value::as_str)?; + let handle = match v.get("host").and_then(Value::as_str) { + Some(host) if !host.is_empty() => format!("{name}@{host}"), + _ => name.to_string(), + }; + let display_name = v + .get("displayName") + .and_then(Value::as_str) + .unwrap_or(name) + .to_string(); + let video_count = v.get("videosCount").and_then(Value::as_u64); + // Newer PeerTube: `avatars: [{path}]`; older: `avatar: {path}`. + let avatar_path = v + .get("avatars") + .and_then(Value::as_array) + .and_then(|a| a.first()) + .and_then(|a| a.get("path")) + .or_else(|| v.get("avatar").and_then(|a| a.get("path"))) + .and_then(Value::as_str); + let avatar_url = avatar_path.map(|p| absolutify(api_base, p)); + Some(RemoteChannelInfo { handle, display_name, video_count, avatar_url }) +} + +fn map_video(v: &Value, api_base: &str, channel: &str) -> RemoteVideo { + let id = v.get("uuid").and_then(Value::as_str).unwrap_or("").to_string(); + let title = v + .get("name") + .and_then(Value::as_str) + .unwrap_or("(untitled)") + .to_string(); + let duration_secs = v.get("duration").and_then(Value::as_f64); + let thumb_url = v + .get("thumbnailPath") + .and_then(Value::as_str) + .map(|p| absolutify(api_base, p)); + RemoteVideo { + id, + title, + channel: channel.to_string(), + video_url: None, + thumb_url, + duration_secs, + } +} + +/// Pick a directly-playable MP4 from a video detail object. Prefers the highest +/// resolution ≤ 1080; falls back to the highest available. HLS-only (empty +/// `files`) → None. +fn pick_media(detail: &Value) -> Option { + let files = detail.get("files").and_then(Value::as_array)?; + if files.is_empty() { + return None; + } + let res = |f: &Value| { + f.get("resolution") + .and_then(|r| r.get("id")) + .and_then(Value::as_u64) + .unwrap_or(0) + }; + // Highest res ≤ 1080, else highest overall. + let best = files + .iter() + .filter(|f| res(f) <= 1080) + .max_by_key(|f| res(f)) + .or_else(|| files.iter().max_by_key(|f| res(f)))?; + best.get("fileUrl").and_then(Value::as_str).map(String::from) +} + +fn parse_tokens(v: &Value) -> Option { + let access = v.get("access_token").and_then(Value::as_str)?.to_string(); + let refresh = v + .get("refresh_token") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + Some(OAuthTokens { access, refresh }) +} + #[cfg(test)] mod tests { use super::*; + use serde_json::json; + + #[test] + fn map_channel_maps_fields_and_absolutifies_avatar() { + let v = json!({ + "name": "blender", "displayName": "Blender Open Movies", + "videosCount": 12, "avatars": [{ "path": "/lazy-static/avatars/x.png" }] + }); + let c = map_channel(&v, "https://framatube.org").unwrap(); + assert_eq!(c.handle, "blender"); + assert_eq!(c.display_name, "Blender Open Movies"); + assert_eq!(c.video_count, Some(12)); + assert_eq!(c.avatar_url.as_deref(), Some("https://framatube.org/lazy-static/avatars/x.png")); + } + + #[test] + fn map_channel_federated_handle_includes_host() { + let v = json!({ "name": "foo", "displayName": "Foo", "host": "other.tld" }); + let c = map_channel(&v, "https://framatube.org").unwrap(); + assert_eq!(c.handle, "foo@other.tld"); + } + + #[test] + fn map_video_maps_and_absolutifies_thumb() { + let v = json!({ + "uuid": "abc-123", "name": "My Vid", "duration": 61, + "thumbnailPath": "/lazy-static/thumbnails/abc.jpg" + }); + let vid = map_video(&v, "https://framatube.org", "Blender"); + assert_eq!(vid.id, "abc-123"); + assert_eq!(vid.title, "My Vid"); + assert_eq!(vid.channel, "Blender"); + assert_eq!(vid.duration_secs, Some(61.0)); + assert_eq!(vid.thumb_url.as_deref(), Some("https://framatube.org/lazy-static/thumbnails/abc.jpg")); + assert!(vid.video_url.is_none()); // resolved later via video_media + } + + #[test] + fn pick_media_prefers_direct_mp4() { + let detail = json!({ + "files": [ + { "resolution": { "id": 480 }, "fileUrl": "https://f/480.mp4" }, + { "resolution": { "id": 1080 }, "fileUrl": "https://f/1080.mp4" } + ] + }); + assert_eq!(pick_media(&detail).as_deref(), Some("https://f/1080.mp4")); + } + + #[test] + fn pick_media_hls_only_is_none() { + let detail = json!({ "files": [], "streamingPlaylists": [{ "playlistUrl": "https://f/master.m3u8" }] }); + assert_eq!(pick_media(&detail), None); + } + + #[test] + fn parse_tokens_reads_access_and_refresh() { + let v = json!({ "access_token": "AAA", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "RRR" }); + let t = parse_tokens(&v).unwrap(); + assert_eq!(t.access, "AAA"); + assert_eq!(t.refresh, "RRR"); + } fn target_str(t: &Target) -> String { match t {