diff --git a/src/video.rs b/src/video.rs index 065f2c2c..bb743452 100644 --- a/src/video.rs +++ b/src/video.rs @@ -1335,6 +1335,27 @@ impl HlsSel { } } +/// Nearest stored quality bucket for an exact format height, so a +/// dropped dialog pin degrades to the picked height instead of the +/// global preference. Exact hits return themselves; anything between +/// buckets rounds to the closest, ties up; heights outside every +/// bucket clamp to the tallest/shortest. Every result is a recognized +/// [`VIDEO_QUALITY_VALUES`] entry (never "best"). +pub fn quality_for_height(height: u32) -> &'static str { + const BUCKETS: &[(u32, &str)] = &[ + (2160, "2160p"), + (1440, "1440p"), + (1080, "1080p"), + (720, "720p"), + (480, "480p"), + ]; + BUCKETS + .iter() + .min_by_key(|(b, _)| (b.abs_diff(height), std::cmp::Reverse(*b))) + .map(|(_, v)| *v) + .unwrap_or("1080p") +} + /// Stored quality value to a height cap: `None` (Best) takes the /// tallest variant available. fn quality_height(value: &str) -> Option { @@ -1745,6 +1766,144 @@ fn find_usable_format(formats: &[Format], id: &str) -> Option { .and_then(|f| StreamSel::from_format(f).ok()) } +/// Planned streams for one attempt: direct splits, an adopted single +/// file, or an HLS variant. Pure over the extracted metadata, so unit +/// tests can pin the selection order without spawning tools. +struct StreamPlan { + video_sel: Option, + audio_sel: Option, + audio_only: bool, + hls_sel: Option, +} + +/// Resolve which streams an attempt fetches, in priority order: +/// +/// 1. A dialog-pinned HLS id resolves first. [`find_usable_format`] +/// only accepts plain HTTPS, so without this the pin would be +/// dropped and then shadowed by the muxed adoption below (x.com +/// VODs: direct mp4s are muxed, so the combo lists HLS only). +/// 2. Direct splits: pinned HTTPS id, else the quality preset. +/// 3. Single-part adoption for a still-missing side: muxed files, then +/// Unclassified video containers (TikTok-style sparse extractors). +/// Gated on the missing side — not on audio absence — because those +/// pages also list a separate audio track, which used to skip both +/// fallbacks and keep only the music. Skipped once a pinned HLS +/// resolved, for satisfied audio-only requests, and when a video +/// request already holds both splits. +/// 4. The HLS preset stays a last resort for rows with no direct audio +/// (a muxed adoption above takes precedence when it found a file). +fn plan_streams( + video: &Video, + quality: &str, + audio_only_request: bool, + video_format_id: Option<&str>, + newest_codecs: bool, + item_id: u64, +) -> StreamPlan { + use yt_dlp::VideoSelection as _; + let pinned_hls: Option = if audio_only_request { + None + } else { + video_format_id.and_then(|id| find_hls_format(&video.formats, id)) + }; + let mut video_sel: Option = if audio_only_request { + None + } else if pinned_hls.is_some() { + // Explicit HLS pick: no direct selection and no fallback + // chatter — the HLS path below consumes the pin. + None + } else if let Some(pinned) = video_format_id { + find_usable_format(&video.formats, pinned).or_else(|| { + tracing::info!( + item_id, + pinned, + "pinned video format gone, falling back to preset" + ); + video + .select_video_format( + selector_for_quality(quality), + codec_preference(newest_codecs), + ) + .and_then(|f| StreamSel::from_format(f).ok()) + }) + } else { + video + .select_video_format( + selector_for_quality(quality), + codec_preference(newest_codecs), + ) + .and_then(|f| StreamSel::from_format(f).ok()) + }; + let mut audio_sel: Option = video + .select_audio_format(AudioQuality::Best, AudioCodecPreference::Any) + .and_then(|f| StreamSel::from_format(f).ok()); + // Muxed-only sources (one file, both tracks — archive.org, file + // lockers): adopt the file directly instead of failing on the missing + // split counterpart. A downloaded track beats a failed row; the + // manifest records the effective single-part mode so retries agree. + // Unclassified last resort (TikTok-style sparse extractors): both + // codec fields missing leaves media typed Unknown — invisible to + // every selector above. Only video-container extensions qualify, + // so storyboards and manifests can never adopt here. + let mut audio_only = audio_only_request; + let mut single_adopted = false; + if pinned_hls.is_none() + && (audio_sel.is_none() || video_sel.is_none()) + && (!audio_only_request || audio_sel.is_none()) + { + let muxed = video_sel.take_if(|v| v.has_audio).or_else(|| { + video + .best_audio_video_format() + .ok() + .and_then(|m| StreamSel::from_format(m).ok()) + }); + if let Some(m) = muxed { + audio_sel = Some(m); + audio_only = true; + single_adopted = true; + } + } + if pinned_hls.is_none() + && !audio_only_request + && video_sel.is_none() + && (audio_sel.is_none() || !single_adopted) + { + let unknown = video.formats.iter().find(|f| { + f.format_type() == FormatType::Unknown + && matches!( + f.download_info.ext, + Extension::Mp4 + | Extension::Webm + | Extension::Avi + | Extension::Flv + | Extension::Ts + ) + && StreamSel::from_format(f).is_ok() + }); + if let Some(m) = unknown.and_then(|m| StreamSel::from_format(m).ok()) { + audio_sel = Some(m); + audio_only = true; + } + } + // HLS fallback (x.com VODs, live replays): nothing above is + // directly fetchable, but manifest variants exist. A resolved pin + // wins outright; otherwise the preset only runs when no direct + // audio survived (a muxed adoption above takes precedence). + let hls_sel: Option = pinned_hls.or_else(|| { + if audio_sel.is_some() { + None + } else { + select_hls_format(&video.formats, quality_height(quality)) + } + }); + StreamPlan { + video_sel, + audio_sel, + audio_only, + hls_sel, + } +} + /// Map a stored quality value to the extractor selector. Unknown values /// fall back to 1080p (same fallback as the combo mapping). pub fn selector_for_quality(value: &str) -> VideoQuality { @@ -2057,7 +2216,6 @@ pub async fn run_video_download( tx: tokio::sync::mpsc::UnboundedSender, ) -> Result, VideoError> { use crate::download::EngineMsg; - use yt_dlp::VideoSelection as _; let staging = staging_dir(job.item_id); tokio::fs::create_dir_all(&staging) @@ -2132,89 +2290,24 @@ pub async fn run_video_download( // codecs stay as automatic fallback, never a failure. Rejections // (HLS/DRM/missing URL) degrade candidates to absent here; the plan // below decides between split, single-file and audio-only from - // what's fetchable. + // what's fetchable (see [`plan_streams`] for the priority order: + // pinned HLS, direct splits, single-part adoption, HLS preset). // A pinned format id (dialog pick) wins over the preset; when it // vanishes from fresh metadata the preset takes over again instead // of failing the row. - let mut video_sel: Option = if job.audio_only { - None - } else if let Some(pinned) = job.video_format_id.as_deref() { - find_usable_format(&video.formats, pinned).or_else(|| { - tracing::info!( - item_id = job.item_id, - pinned, - "pinned video format gone, falling back to preset" - ); - video - .select_video_format( - selector_for_quality(&job.quality), - codec_preference(job.newest_codecs), - ) - .and_then(|f| StreamSel::from_format(f).ok()) - }) - } else { - video - .select_video_format( - selector_for_quality(&job.quality), - codec_preference(job.newest_codecs), - ) - .and_then(|f| StreamSel::from_format(f).ok()) - }; - let mut audio_sel: Option = video - .select_audio_format(AudioQuality::Best, AudioCodecPreference::Any) - .and_then(|f| StreamSel::from_format(f).ok()); - // Muxed-only sources (one file, both tracks — archive.org, file - // lockers): adopt the file directly instead of failing on the missing - // split counterpart. A downloaded track beats a failed row; the - // manifest records the effective single-part mode so retries agree. - // Unclassified last resort (TikTok-style sparse extractors): both - // codec fields missing leaves media typed Unknown — invisible to - // every selector above. Only video-container extensions qualify, - // so storyboards and manifests can never adopt here. - let mut audio_only = job.audio_only; - if audio_sel.is_none() { - let muxed = video_sel.take_if(|v| v.has_audio).or_else(|| { - video - .best_audio_video_format() - .ok() - .and_then(|m| StreamSel::from_format(m).ok()) - }); - if let Some(m) = muxed { - audio_sel = Some(m); - audio_only = true; - } - } - if audio_sel.is_none() { - let unknown = video.formats.iter().find(|f| { - f.format_type() == FormatType::Unknown - && matches!( - f.download_info.ext, - Extension::Mp4 - | Extension::Webm - | Extension::Avi - | Extension::Flv - | Extension::Ts - ) - && StreamSel::from_format(f).is_ok() - }); - if let Some(m) = unknown.and_then(|m| StreamSel::from_format(m).ok()) { - audio_sel = Some(m); - audio_only = true; - } - } - // HLS fallback (x.com VODs, live replays): nothing above is - // directly fetchable, but manifest variants exist. VOD captures go - // through yt-dlp (variant/audio selection, retries, merging); live - // captures stay on direct ffmpeg for stop-and-keep. A dialog-pinned - // HLS id wins over the quality preset. - let hls_sel: Option = if audio_sel.is_some() { - None - } else { - job.video_format_id - .as_deref() - .and_then(|id| find_hls_format(&video.formats, id)) - .or_else(|| select_hls_format(&video.formats, quality_height(&job.quality))) - }; + let StreamPlan { + video_sel, + audio_sel, + audio_only, + hls_sel, + } = plan_streams( + &video, + &job.quality, + job.audio_only, + job.video_format_id.as_deref(), + job.newest_codecs, + job.item_id, + ); if let Some(mut hls) = hls_sel { // An abort that fired during resolve means stop-before-start: // for live rows there is deliberately no pauser preset waiting diff --git a/src/video_tests.rs b/src/video_tests.rs index e21adeaa..c1b504b0 100644 --- a/src/video_tests.rs +++ b/src/video_tests.rs @@ -1766,3 +1766,192 @@ fn user_installed_tools_win_over_bundle() { drop(_env); let _ = std::fs::remove_dir_all(&dir); } + +// ── stream planning (selection gating) ─────────────────────────────── + +/// TikTok shape: sparse mp4 with neither codec field (typed Unknown, +/// invisible to every crate selector) beside a separate music track. +fn tiktok_like_video() -> yt_dlp::model::Video { + let sparse_mp4 = serde_json::json!({ + "format": "dl", + "format_id": "dl", + "protocol": "https", + "ext": "mp4", + "url": "https://cdn.example/dl.mp4", + "http_headers": {}, + }); + test_video(serde_json::json!([ + sparse_mp4, + test_format_full("music", "none", "mp4a.40.2", None, None, "https", false), + ])) +} + +/// x.com shape: muxed direct mp4s (AudioVideo type, invisible to the +/// video-only selector) beside HLS variants. +fn x_like_video() -> yt_dlp::model::Video { + test_video(serde_json::json!([ + test_format_full( + "http-320", + "avc1.64001f", + "mp4a.40.2", + Some(320), + None, + "https", + false + ), + test_format_full( + "http-720", + "avc1.64001f", + "mp4a.40.2", + Some(720), + None, + "https", + false + ), + test_format_full( + "hls-720", + "avc1.64001f", + "mp4a.40.2", + Some(720), + None, + "m3u8_native", + false + ), + ])) +} + +#[test] +fn plan_adopts_unknown_video_despite_separate_audio() { + // The TikTok gating bug: both fallbacks keyed off audio absence, so + // the music track suppressed them and only the music survived. + let video = tiktok_like_video(); + let plan = plan_streams(&video, "1080p", false, None, true, 1); + assert!(plan.video_sel.is_none()); + assert_eq!(plan.audio_sel.expect("adopted").format_id, "dl"); + assert!(plan.audio_only); + assert!(plan.hls_sel.is_none()); +} + +#[test] +fn plan_pinned_hls_wins_over_muxed_adoption() { + // The x.com shadowing bug: the pin was dropped by the HTTPS-only + // lookup and the muxed adoption then vetoed the HLS path. + let video = x_like_video(); + let plan = plan_streams(&video, "720p", false, Some("hls-720"), true, 1); + assert!(plan.video_sel.is_none()); + assert!(plan.audio_sel.is_none()); + assert!(!plan.audio_only); + let hls = plan.hls_sel.expect("pinned hls"); + assert_eq!(hls.height, Some(720)); +} + +#[test] +fn plan_muxed_only_still_adopts_without_pin() { + // No pin, no splits: the muxed file adopts as before (precedence + // over the HLS preset is unchanged). + let video = x_like_video(); + let plan = plan_streams(&video, "1080p", false, None, true, 1); + assert!(plan.video_sel.is_none()); + assert_eq!(plan.audio_sel.expect("adopted").format_id, "http-320"); + assert!(plan.audio_only); + assert!(plan.hls_sel.is_none()); +} + +#[test] +fn plan_stale_hls_pin_degrades_to_muxed_adoption() { + // A vanished HLS pin behaves like no pin: preset, then adoption. + let video = x_like_video(); + let plan = plan_streams(&video, "1080p", false, Some("gone"), true, 1); + assert_eq!(plan.audio_sel.expect("adopted").format_id, "http-320"); + assert!(plan.audio_only); + assert!(plan.hls_sel.is_none()); +} + +#[test] +fn plan_splits_stay_split() { + let video = test_video(serde_json::json!([ + test_format_full("v", "avc1.640028", "none", Some(1080), None, "https", false), + test_format_full("a", "none", "mp4a.40.2", None, None, "https", false), + ])); + let plan = plan_streams(&video, "1080p", false, None, true, 1); + assert_eq!(plan.video_sel.expect("video").format_id, "v"); + assert_eq!(plan.audio_sel.expect("audio").format_id, "a"); + assert!(!plan.audio_only); + assert!(plan.hls_sel.is_none()); +} + +#[test] +fn plan_satisfied_audio_only_request_untouched() { + // A fulfilled audio-only request keeps its track even when a muxed + // file is also listed: adoption must not swap it out. + let video = test_video(serde_json::json!([ + test_format_full("a", "none", "mp4a.40.2", None, None, "https", false), + test_format_full( + "m", + "avc1.64001f", + "mp4a.40.2", + Some(720), + None, + "https", + false + ), + ])); + let plan = plan_streams(&video, "1080p", true, None, true, 1); + assert!(plan.video_sel.is_none()); + assert_eq!(plan.audio_sel.expect("audio").format_id, "a"); + assert!(plan.audio_only); + assert!(plan.hls_sel.is_none()); +} + +#[test] +fn plan_hls_preset_still_serves_hls_only_pages() { + let video = test_video(serde_json::json!([ + test_format_full( + "h480", + "avc1", + "mp4a.40.2", + Some(480), + None, + "m3u8_native", + false + ), + test_format_full( + "h1080", + "avc1", + "mp4a.40.2", + Some(1080), + None, + "m3u8_native", + false + ), + ])); + let plan = plan_streams(&video, "720p", false, None, true, 1); + assert!(plan.video_sel.is_none()); + assert!(plan.audio_sel.is_none()); + assert!(!plan.audio_only); + assert_eq!(plan.hls_sel.expect("preset hls").height, Some(1080)); +} + +// ── quality for height ─────────────────────────────────────────────── + +#[test] +fn quality_for_height_buckets() { + assert_eq!(quality_for_height(2160), "2160p"); + assert_eq!(quality_for_height(1440), "1440p"); + assert_eq!(quality_for_height(1080), "1080p"); + assert_eq!(quality_for_height(720), "720p"); + assert_eq!(quality_for_height(480), "480p"); + // Odd extractor heights round to the closest bucket (ties up), and + // everything outside clamps — so the result is always a recognized + // stored value, never a silent 1080p fallback. + assert_eq!(quality_for_height(632), "720p"); + assert_eq!(quality_for_height(900), "1080p"); + assert_eq!(quality_for_height(100), "480p"); + assert_eq!(quality_for_height(5000), "2160p"); + for h in [240, 360, 480, 720, 1080, 1440, 2160] { + assert!( + VIDEO_QUALITY_VALUES.contains(&quality_for_height(h)), + "bucket for {h}" + ); + } +} diff --git a/src/window.rs b/src/window.rs index 029d8e35..c56ca9d3 100644 --- a/src/window.rs +++ b/src/window.rs @@ -2058,14 +2058,21 @@ pub fn show_add_dialog(manager: Rc) { } else { Some(typed.as_str()) }; - // The fallback quality always comes from live - // preferences; the exact pick (if any) rides along. - let quality = m.settings().video_quality(); - let format_id = formats - .borrow() - .get(step2.quality.selected() as usize) - .cloned() - .flatten(); + // The fallback quality follows the picked format's + // height, so a dropped pin still degrades to the + // chosen height; "Best match" (index 0) keeps the + // live global preference. The combo lists Best + // match first, then the info formats in order. + let selected = step2.quality.selected() as usize; + let format_id = formats.borrow().get(selected).cloned().flatten(); + let quality = match selected { + 0 => m.settings().video_quality(), + i => v + .formats + .get(i - 1) + .map(|opt| crate::video::quality_for_height(opt.height).to_string()) + .unwrap_or_else(|| m.settings().video_quality()), + }; match m.enqueue_video( &v.page_url, Some(&dd.borrow()),