use crate::models::{CookieSource, VideoMetadata}; use serde::Deserialize; use std::collections::HashMap; use std::process::Command; #[derive(Debug, Deserialize)] pub struct SubtitleFormat { pub ext: Option, pub url: Option, } #[derive(Debug, Deserialize)] pub struct YtDlpJson { pub title: String, pub duration: Option, pub fps: Option, pub thumbnail: Option, /// Manually authored subtitles: language_code -> [formats] #[serde(default)] pub subtitles: HashMap>, /// Auto-generated subtitles: language_code -> [formats] #[serde(default)] pub automatic_captions: HashMap>, } /// Check if subtitles have a VTT format for English (en, en-US, en-GB, en-orig). fn find_english_key(map: &HashMap>) -> Option<&str> { // Prefer exact "en", then any en-* variant for key in ["en", "en-US", "en-GB", "en-orig"] { if map.contains_key(key) { return Some(key); } } // Try any key starting with "en" for key in map.keys() { if key.starts_with("en") { return Some(key.as_str()); } } None } /// Determine caption availability: (has_captions, is_auto) pub fn detect_captions(meta: &YtDlpJson) -> (bool, bool) { // Priority 1: manual English subtitles if find_english_key(&meta.subtitles).is_some() { return (true, false); } // Priority 2: auto-generated English captions if find_english_key(&meta.automatic_captions).is_some() { return (true, true); } (false, false) } pub fn parse_ytdlp_json(json_str: &str) -> Result { serde_json::from_str::(json_str) .map_err(|e| format!("Failed to parse yt-dlp JSON: {e}")) } pub fn resolve(url: &str, cookie_source: &CookieSource) -> Result { let cookie_args = cookie_source.to_ytdlp_args(); let mut meta_cmd = Command::new("yt-dlp"); meta_cmd .arg("--dump-json") .arg("--no-download") .arg("--remote-components") .arg("ejs:github"); for arg in &cookie_args { meta_cmd.arg(arg); } meta_cmd.arg(url); let meta_output = meta_cmd .output() .map_err(|e| format!("Failed to run yt-dlp: {e}"))?; if !meta_output.status.success() { let stderr = String::from_utf8_lossy(&meta_output.stderr); return Err(format!("yt-dlp metadata failed: {stderr}")); } let json_str = String::from_utf8_lossy(&meta_output.stdout); let meta = parse_ytdlp_json(&json_str)?; let (has_captions, captions_are_auto) = detect_captions(&meta); let mut stream_cmd = Command::new("yt-dlp"); stream_cmd .arg("-g") .arg("--no-download") .arg("--remote-components") .arg("ejs:github"); for arg in &cookie_args { stream_cmd.arg(arg); } stream_cmd.arg(url); let stream_output = stream_cmd .output() .map_err(|e| format!("Failed to get stream URL: {e}"))?; if !stream_output.status.success() { let stderr = String::from_utf8_lossy(&stream_output.stderr); return Err(format!("yt-dlp stream URL failed: {stderr}")); } let stream_url = String::from_utf8_lossy(&stream_output.stdout) .lines() .next() .unwrap_or("") .trim() .to_string(); if stream_url.is_empty() { return Err("yt-dlp returned an empty stream URL".to_string()); } Ok(VideoMetadata { url: url.to_string(), title: meta.title, duration: meta.duration.unwrap_or(0.0), fps: meta.fps.unwrap_or(30.0), thumbnail_url: meta.thumbnail, stream_url, has_captions, captions_are_auto, }) } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_ytdlp_json_valid() { let json = r#"{"title": "Test Video", "duration": 125.5, "fps": 29.97, "thumbnail": "https://i.ytimg.com/vi/abc/hqdefault.jpg"}"#; let result = parse_ytdlp_json(json).unwrap(); assert_eq!(result.title, "Test Video"); assert!((result.duration.unwrap() - 125.5).abs() < 0.001); assert!((result.fps.unwrap() - 29.97).abs() < 0.001); assert_eq!( result.thumbnail.unwrap(), "https://i.ytimg.com/vi/abc/hqdefault.jpg" ); } #[test] fn test_parse_ytdlp_json_missing_optional_fields() { let json = r#"{"title": "Minimal Video"}"#; let result = parse_ytdlp_json(json).unwrap(); assert_eq!(result.title, "Minimal Video"); assert!(result.duration.is_none()); assert!(result.fps.is_none()); assert!(result.thumbnail.is_none()); assert!(result.subtitles.is_empty()); assert!(result.automatic_captions.is_empty()); } #[test] fn test_parse_ytdlp_json_invalid() { let result = parse_ytdlp_json("not json"); assert!(result.is_err()); } #[test] fn test_detect_captions_manual() { let json = r#"{"title": "T", "subtitles": {"en": [{"ext": "vtt", "url": "http://x"}]}, "automatic_captions": {}}"#; let meta = parse_ytdlp_json(json).unwrap(); let (has, is_auto) = detect_captions(&meta); assert!(has); assert!(!is_auto); } #[test] fn test_detect_captions_auto_only() { let json = r#"{"title": "T", "subtitles": {}, "automatic_captions": {"en": [{"ext": "vtt", "url": "http://x"}]}}"#; let meta = parse_ytdlp_json(json).unwrap(); let (has, is_auto) = detect_captions(&meta); assert!(has); assert!(is_auto); } #[test] fn test_detect_captions_none() { let json = r#"{"title": "T"}"#; let meta = parse_ytdlp_json(json).unwrap(); let (has, _) = detect_captions(&meta); assert!(!has); } #[test] fn test_detect_captions_en_variant() { let json = r#"{"title": "T", "subtitles": {"en-US": [{"ext": "vtt"}]}}"#; let meta = parse_ytdlp_json(json).unwrap(); let (has, is_auto) = detect_captions(&meta); assert!(has); assert!(!is_auto); } }