diff --git a/src/video.rs b/src/video.rs index 320b212ed..18c8a1dff 100644 --- a/src/video.rs +++ b/src/video.rs @@ -1359,6 +1359,35 @@ pub fn quality_for_height(height: u32) -> &'static str { .unwrap_or("1080p") } +/// Best muxed (audio+video) file for a height cap: smallest height at +/// or above the cap, else the tallest available. Same semantics as +/// [`select_hls_format`]. Only directly fetchable files qualify, so a +/// DRM or link-less entry can never shadow a playable one — and the +/// quality cap survives: callers used to take the crate's +/// `best_audio_video_format`, which is first-in-extractor-order (lowest +/// first on x.com) regardless of the requested height. +fn select_muxed_format(formats: &[Format], want: Option) -> Option { + let mut cands: Vec<(&Format, u32)> = formats + .iter() + .filter(|f| f.format_type().is_audio_and_video()) + .filter_map(|f| { + let h = f.video_resolution.height.filter(|&h| h > 0)?; + StreamSel::from_format(f).ok().map(|_| (f, h)) + }) + .collect(); + cands.sort_by_key(|(_, h)| *h); + match want { + Some(cap) => cands + .iter() + .find(|(_, h)| *h >= cap) + .or_else(|| cands.last()) + .and_then(|(f, _)| StreamSel::from_format(f).ok()), + None => cands + .last() + .and_then(|(f, _)| StreamSel::from_format(f).ok()), + } +} + /// Stored quality value to a height cap: `None` (Best) takes the /// tallest variant available. fn quality_height(value: &str) -> Option { @@ -1789,7 +1818,8 @@ struct StreamPlan { /// 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 +/// 3. Single-part adoption for a still-missing side: muxed files (at +/// the requested height, not first-in-extractor-order), 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 @@ -1858,10 +1888,10 @@ fn plan_streams( && (!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()) + // Height-aware: a bare "first muxed file" pick is lowest + // first on extractors that list ascending (x.com), ignoring + // the requested quality entirely. + select_muxed_format(&video.formats, quality_height(quality)) }); if let Some(m) = muxed { audio_sel = Some(m); diff --git a/src/video_tests.rs b/src/video_tests.rs index e4482463b..419e49559 100644 --- a/src/video_tests.rs +++ b/src/video_tests.rs @@ -1848,21 +1848,73 @@ fn plan_pinned_hls_wins_over_muxed_adoption() { #[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). + // over the HLS preset is unchanged) — but at the requested height, + // not first-in-extractor-order. 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_eq!(plan.audio_sel.expect("adopted").format_id, "http-720"); assert!(plan.audio_only); assert!(plan.hls_sel.is_none()); } +#[test] +fn plan_muxed_adoption_honors_height_cap() { + // First-in-order used to win regardless of quality (x.com lists + // ascending, so Best match downloaded the lowest). Now the cap + // picks smallest-at-or-above, tallest when nothing qualifies. + let video = test_video(serde_json::json!([ + test_format_full( + "m320", + "avc1.64001f", + "mp4a.40.2", + Some(320), + None, + "https", + false + ), + test_format_full( + "m720", + "avc1.64001f", + "mp4a.40.2", + Some(720), + None, + "https", + false + ), + test_format_full( + "m1080", + "avc1.64001f", + "mp4a.40.2", + Some(1080), + None, + "https", + false + ), + ])); + for (quality, want) in [ + ("best", "m1080"), + ("1080p", "m1080"), + ("720p", "m720"), + ("480p", "m720"), + ] { + let plan = plan_streams(&video, quality, false, None, true, 1); + assert_eq!( + plan.audio_sel.expect("adopted").format_id, + want, + "quality {quality}" + ); + assert!(plan.audio_only, "quality {quality}"); + } +} + #[test] fn plan_stale_hls_pin_degrades_to_muxed_adoption() { - // A vanished HLS pin behaves like no pin: preset, then adoption. + // A vanished HLS pin behaves like no pin: preset, then adoption + // (at the dialog-picked height, not the lowest listing). 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_eq!(plan.audio_sel.expect("adopted").format_id, "http-720"); assert!(plan.audio_only); assert!(plan.hls_sel.is_none()); }