From 226c7913f500f95be186068db33becb5fce3720b Mon Sep 17 00:00:00 2001 From: NimBold Date: Sun, 21 Jun 2026 00:21:59 +0330 Subject: [PATCH] fix(media): correct HLS progress tracking Use fragment progress for HLS downloads, prevent false multi-track completion, require exact resolution matches, and remove the ambiguous Best format option and obsolete frontend parser. --- src-tauri/src/lib.rs | 172 ++++++++++++--------- src/components/AddDownloadsModal.tsx | 218 +-------------------------- src/components/QualityModal.tsx | 2 +- 3 files changed, 104 insertions(+), 288 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9773219..20e4b07 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -186,12 +186,7 @@ fn matches_media_height(value: &serde_json::Value, target: u64) -> bool { return true; } - let Some(height) = format_height(value) else { - return false; - }; - - let tolerance = if target >= 2160 { 600 } else if target >= 1440 { 400 } else if target >= 1080 { 300 } else if target >= 720 { 200 } else { 100 }; - height <= target && height.saturating_add(tolerance) >= target + format_height(value) == Some(target) } fn format_score(value: &serde_json::Value) -> u64 { @@ -343,7 +338,6 @@ fn raw_media_format( fn build_media_format_options( formats_arr: &[serde_json::Value], duration_seconds: Option, - requested_formats: Option<&[serde_json::Value]>, ) -> Vec { let clean_formats: Vec<&serde_json::Value> = formats_arr.iter().filter(|format| !is_excluded_yt_dlp_format(format)).collect(); let has_video = clean_formats.iter().any(|format| has_video_stream(format)); @@ -351,29 +345,6 @@ fn build_media_format_options( let mut options = Vec::new(); if has_video { - let requested_video = requested_formats - .and_then(|formats| formats.iter().find(|format| has_video_stream(format))); - let requested_audio = requested_formats - .and_then(|formats| formats.iter().find(|format| has_audio_stream(format) && !has_video_stream(format))); - let best_video = requested_video.or_else(|| best_matching_format(&clean_formats, has_video_stream)); - let best_audio = if best_video.is_some_and(|format| has_audio_stream(format)) { - None - } else { - requested_audio.or_else(|| best_audio_format(&clean_formats, None)) - }; - let (filesize, filesize_approx) = - estimated_merged_size(best_video, best_audio, duration_seconds); - options.push(MediaFormat { - format_id: selected_format_id(best_video, best_audio) - .unwrap_or_else(|| "bestvideo+bestaudio/best".to_string()), - resolution: "Best".to_string(), - ext: "mkv".to_string(), - format_label: joined_format_label("mkv", best_video.and_then(|format| json_str(format, "vcodec")), best_audio.and_then(|format| json_str(format, "acodec"))), - fps: best_video.and_then(|format| json_f64(format, "fps")), - filesize, - filesize_approx, - }); - let available_heights: Vec = [2160_u64, 1440, 1080, 720, 480, 360] .into_iter() .filter(|height| clean_formats.iter().any(|format| matches_media_height(format, *height))) @@ -534,7 +505,19 @@ fn parse_media_progress_line(line: &str) -> Option { let total = progress_json_number(&progress, "total_bytes") .or_else(|| progress_json_number(&progress, "total_bytes_estimate")) .unwrap_or(0.0); - let fraction = if total > 0.0 { + let fragment_index = progress_json_number(&progress, "fragment_index"); + let fragment_count = progress_json_number(&progress, "fragment_count"); + let fraction = if let (Some(fragment_index), Some(fragment_count)) = + (fragment_index, fragment_count) + { + if fragment_count > 1.0 { + (fragment_index / fragment_count).clamp(0.0, 1.0) + } else if total > 0.0 { + downloaded / total + } else { + 0.0 + } + } else if total > 0.0 { downloaded / total } else { progress_json_string(&progress, "_percent_str") @@ -662,6 +645,24 @@ fn media_progress_speed(progress: &MediaProgress, now: Instant, last_sample: &mu } } +fn aggregate_media_fraction( + total_tracks: f64, + current_track: &mut f64, + last_fraction: &mut f64, + next_fraction: f64, +) -> f64 { + let next_fraction = next_fraction.clamp(0.0, 1.0); + let has_next_track = *current_track + 1.0 < total_tracks; + if has_next_track && *last_fraction >= 0.95 && next_fraction <= 0.05 { + *current_track += 1.0; + *last_fraction = next_fraction; + } else { + *last_fraction = (*last_fraction).max(next_fraction); + } + + ((*current_track + *last_fraction) / total_tracks).clamp(0.0, 1.0) +} + async fn cleanup_media_processing_artifacts(out_path: &std::path::Path) { let Some(parent) = out_path.parent() else { return; @@ -990,7 +991,7 @@ async fn fetch_media_metadata_uncached(app_handle: tauri::AppHandle, url: String .arg("--retries").arg("3") .arg("--extractor-retries").arg("3") .arg("--compat-options").arg("no-youtube-unavailable-videos") - .arg("--print").arg("%(.{title,duration,thumbnail,formats,requested_formats})j"); + .arg("--print").arg("%(.{title,duration,thumbnail,formats})j"); if let Some(browser) = cookie_browser { if !browser.is_empty() { @@ -1035,17 +1036,10 @@ async fn fetch_media_metadata_uncached(app_handle: tauri::AppHandle, url: String let duration_seconds = value.get("duration").and_then(|v| v.as_f64()); let duration = duration_seconds.map(|v| v as u64); let thumbnail = value.get("thumbnail").and_then(|v| v.as_str()).map(|s| s.to_string()); - let requested_formats = value - .get("requested_formats") - .and_then(|v| v.as_array()) - .map(Vec::as_slice); - let formats = value .get("formats") .and_then(|v| v.as_array()) - .map(|formats_arr| { - build_media_format_options(formats_arr, duration_seconds, requested_formats) - }) + .map(|formats_arr| build_media_format_options(formats_arr, duration_seconds)) .unwrap_or_default(); Ok(MediaMetadata { title, duration, thumbnail, formats }) @@ -2106,15 +2100,18 @@ pub(crate) async fn start_media_download_internal( Some(tauri_plugin_shell::process::CommandEvent::Stdout(line_bytes)) => { let line = String::from_utf8_lossy(&line_bytes); if let Some(progress) = parse_media_progress_line(&line) { - if progress.fraction < last_fraction && (last_fraction - progress.fraction) > 0.5 { - current_track += 1.0; + let previous_track = current_track; + let overall_fraction = aggregate_media_fraction( + total_tracks, + &mut current_track, + &mut last_fraction, + progress.fraction, + ); + if current_track != previous_track { last_speed_sample = None; } - last_fraction = progress.fraction; let speed = media_progress_speed(&progress, std::time::Instant::now(), &mut last_speed_sample); - let overall_fraction = ((current_track + progress.fraction) / total_tracks).min(1.0); - let now = std::time::Instant::now(); if now.duration_since(last_progress_at) >= std::time::Duration::from_millis(200) { let _ = app_handle.emit("download-progress", DownloadProgressEvent { @@ -2132,14 +2129,17 @@ pub(crate) async fn start_media_download_internal( Some(tauri_plugin_shell::process::CommandEvent::Stderr(line_bytes)) => { let line = String::from_utf8_lossy(&line_bytes); if let Some(progress) = parse_media_progress_line(&line) { - if progress.fraction < last_fraction && (last_fraction - progress.fraction) > 0.5 { - current_track += 1.0; + let previous_track = current_track; + let overall_fraction = aggregate_media_fraction( + total_tracks, + &mut current_track, + &mut last_fraction, + progress.fraction, + ); + if current_track != previous_track { last_speed_sample = None; } - last_fraction = progress.fraction; let speed = media_progress_speed(&progress, std::time::Instant::now(), &mut last_speed_sample); - - let overall_fraction = ((current_track + progress.fraction) / total_tracks).min(1.0); let now = std::time::Instant::now(); if now.duration_since(last_progress_at) >= std::time::Duration::from_millis(200) { let _ = app_handle.emit("download-progress", DownloadProgressEvent { @@ -3093,9 +3093,10 @@ fn set_extension_frontend_ready( #[cfg(test)] mod tests { use super::{ - build_media_format_options, collect_download_uris, is_excluded_yt_dlp_format, json_lower, - media_progress_speed, normalize_speed_limit_for_aria2, parse_firelink_urls, - parse_media_progress_line, MediaProgress, MEDIA_PROGRESS_PREFIX, + aggregate_media_fraction, build_media_format_options, collect_download_uris, + is_excluded_yt_dlp_format, json_lower, media_progress_speed, + normalize_speed_limit_for_aria2, parse_firelink_urls, parse_media_progress_line, + MediaProgress, MEDIA_PROGRESS_PREFIX, }; use serde_json::json; use std::time::{Duration, Instant}; @@ -3200,9 +3201,11 @@ mod tests { }) ]; - let options = build_media_format_options(&formats, Some(600.0), None); + let options = build_media_format_options(&formats, Some(600.0)); assert!(!options.iter().any(|format| format.ext == "mhtml")); + assert!(!options.iter().any(|format| format.resolution == "Best")); + assert!(!options.iter().any(|format| format.resolution == "1440p")); assert!(options.iter().any(|format| { format.resolution == "1080p" && format.ext == "mkv" @@ -3240,18 +3243,15 @@ mod tests { "filesize": 28_000_000_u64 }) ]; - let requested_formats = vec![formats[0].clone(), formats[1].clone()]; + let options = build_media_format_options(&formats, Some(1_800.0)); + let option = options + .iter() + .find(|format| format.resolution == "1080p" && format.ext == "mkv") + .expect("1080p MKV option"); - let options = build_media_format_options( - &formats, - Some(1_800.0), - Some(&requested_formats), - ); - let best = options.first().expect("best format option"); - - assert_eq!(best.format_id, "301+251"); - assert_eq!(best.filesize, None); - assert_eq!(best.filesize_approx, Some(1_468_000_000)); + assert_eq!(option.format_id, "301+251"); + assert_eq!(option.filesize, None); + assert_eq!(option.filesize_approx, Some(1_468_000_000)); } #[test] @@ -3295,11 +3295,7 @@ mod tests { .filter(|format| json_lower(format, "ext") == "mhtml") .count(); let duration = value.get("duration").and_then(|duration| duration.as_f64()); - let requested_formats = value - .get("requested_formats") - .and_then(|requested| requested.as_array()) - .map(Vec::as_slice); - let options = build_media_format_options(formats, duration, requested_formats); + let options = build_media_format_options(formats, duration); eprintln!( "raw formats: {}, raw mhtml: {}, normalized options: {}", @@ -3332,6 +3328,42 @@ mod tests { ); } + #[test] + fn uses_fragment_progress_instead_of_temporary_hls_size_estimates() { + let line = format!( + "{MEDIA_PROGRESS_PREFIX}{{\"downloaded_bytes\":1024,\"total_bytes_estimate\":1024,\"fragment_index\":0,\"fragment_count\":354,\"_percent_str\":\"100.0%\"}}" + ); + + assert_eq!( + parse_media_progress_line(&line).map(|progress| progress.fraction), + Some(0.0) + ); + } + + #[test] + fn advances_tracks_only_after_a_completed_track_restarts() { + let mut current_track = 0.0; + let mut last_fraction = 0.0; + + assert_eq!( + aggregate_media_fraction(2.0, &mut current_track, &mut last_fraction, 0.5), + 0.25 + ); + assert_eq!( + aggregate_media_fraction(2.0, &mut current_track, &mut last_fraction, 1.0), + 0.5 + ); + assert_eq!( + aggregate_media_fraction(2.0, &mut current_track, &mut last_fraction, 0.0), + 0.5 + ); + assert_eq!( + aggregate_media_fraction(2.0, &mut current_track, &mut last_fraction, 0.4), + 0.7 + ); + assert_eq!(current_track, 1.0); + } + #[test] fn derives_main_window_speed_from_downloaded_byte_delta() { let first = MediaProgress { diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index cd04e19..5b39797 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -16,18 +16,6 @@ import { resolveDownloadFilePath } from '../utils/downloadLocations'; -interface RawMediaFormat { - format_id?: string; - ext?: string; - resolution?: string; - format_note?: string; - vcodec?: string; - acodec?: string; - height?: number; - filesize?: number; - filesize_approx?: number; -} - interface MediaFormat { name: string; selector: string; @@ -50,100 +38,6 @@ interface ParsedDownloadItem { selectedFormat?: number; } -const isVideo = (f: RawMediaFormat) => { - const vcodec = f.vcodec?.toLowerCase(); - return vcodec && vcodec !== 'none'; -}; - -const isAudio = (f: RawMediaFormat) => { - const acodec = f.acodec?.toLowerCase(); - const vcodec = f.vcodec?.toLowerCase(); - return acodec && acodec !== 'none' && (!vcodec || vcodec === 'none'); -}; - -const formatSize = (f: RawMediaFormat) => f.filesize ?? f.filesize_approx ?? 0; - -const matchesHeight = (f: RawMediaFormat, height: number | null) => { - if (height === null) return true; - - const note = f.format_note || ""; - if (height === 2160 && (note.includes("2160p") || note.toLowerCase().includes("4k"))) return true; - if (height === 1440 && note.includes("1440p")) return true; - if (height === 1080 && note.includes("1080p")) return true; - if (height === 720 && note.includes("720p")) return true; - if (height === 480 && note.includes("480p")) return true; - if (height === 360 && note.includes("360p")) return true; - - if (f.resolution) { - const parts = f.resolution.split('x').map(n => parseInt(n, 10)); - if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) { - const maxDim = Math.max(parts[0], parts[1]); - switch (height) { - case 2160: if (maxDim >= 3800) return true; break; - case 1440: if (maxDim >= 2500 && maxDim < 3800) return true; break; - case 1080: if (maxDim >= 1900 && maxDim < 2500) return true; break; - case 720: if (maxDim >= 1200 && maxDim < 1900) return true; break; - case 480: if (maxDim >= 800 && maxDim < 1200) return true; break; - case 360: if (maxDim >= 600 && maxDim < 800) return true; break; - } - } - } - - const formatHeight = f.height; - if (!formatHeight) return false; - - let tolerance = 100; - if (height >= 2160) tolerance = 600; - else if (height >= 1440) tolerance = 400; - else if (height >= 1080) tolerance = 300; - else if (height >= 720) tolerance = 200; - - return formatHeight <= height && formatHeight >= height - tolerance; -}; - -const hasVideoFormat = (formats: RawMediaFormat[], height: number | null, container: string) => { - return formats.some(f => { - if (!isVideo(f) || !matchesHeight(f, height)) return false; - return container === 'mkv' || f.ext?.toLowerCase() === container.toLowerCase(); - }); -}; - -const hasAudioFormat = (formats: RawMediaFormat[], ext: string | null) => { - return formats.some(f => { - if (!isAudio(f)) return false; - if (!ext) return true; - return f.ext?.toLowerCase() === ext.toLowerCase(); - }); -}; - -const estimatedVideoBytes = (formats: RawMediaFormat[], height: number | null, container: string) => { - let maxVideo = 0; - for (const f of formats) { - if (isVideo(f) && matchesHeight(f, height) && (container === 'mkv' || f.ext?.toLowerCase() === container.toLowerCase())) { - const size = formatSize(f); - if (size > maxVideo) maxVideo = size; - } - } - if (maxVideo === 0) return null; - - let maxAudio = estimatedAudioBytes(formats, container === 'webm' ? 'webm' : 'm4a') || estimatedAudioBytes(formats, null) || 0; - return maxVideo + maxAudio; -}; - -const estimatedAudioBytes = (formats: RawMediaFormat[], ext: string | null): number | null => { - let maxPreferred = 0; - for (const f of formats) { - if (isAudio(f)) { - if (!ext || f.ext?.toLowerCase() === ext.toLowerCase()) { - const size = formatSize(f); - if (size > maxPreferred) maxPreferred = size; - } - } - } - if (maxPreferred > 0 || !ext) return maxPreferred > 0 ? maxPreferred : null; - return estimatedAudioBytes(formats, null); -}; - const formatBytes = (bytes: number) => { if (bytes === 0) return 'Unknown size'; const k = 1024; @@ -152,116 +46,6 @@ const formatBytes = (bytes: number) => { return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; }; -// @ts-ignore -interface QualityOption { - h: number; - name: string; -} - -const standardResolutions = [ - { h: 2160, name: "4K" }, - { h: 1440, name: "1440p" }, - { h: 1080, name: "1080p" }, - { h: 720, name: "720p" }, - { h: 480, name: "480p" }, - { h: 360, name: "360p" } -]; - -// @ts-ignore -const parseMediaFormats = (jsonStr: string) => { - try { - const parsed: unknown = JSON.parse(jsonStr); - if (!parsed || typeof parsed !== 'object') return null; - const data = parsed as { title?: unknown; formats?: unknown }; - let title = typeof data.title === 'string' ? data.title : 'Media'; - title = title.replace(/[\/\\?%*:|"<>]/g, '-'); - const rawFormats = Array.isArray(data.formats) - ? data.formats.filter((format): format is RawMediaFormat => Boolean(format) && typeof format === 'object') - : []; - - const options = []; - - const availableResolutions = standardResolutions.filter(res => - rawFormats.some(f => isVideo(f) && matchesHeight(f, res.h)) - ); - - const videoQualities: { h: number | null, name: string }[] = [{ h: null, name: "Best" }, ...availableResolutions]; - const videoContainers = [ - { ext: "mp4", name: "MP4" }, - { ext: "mkv", name: "MKV" }, - { ext: "webm", name: "WebM" } - ]; - - for (const q of videoQualities) { - for (const c of videoContainers) { - if (!hasVideoFormat(rawFormats, q.h, c.ext)) continue; - const est = estimatedVideoBytes(rawFormats, q.h, c.ext); - const filter = q.h ? `[height<=${q.h}]` : ''; - - let selector = `bestvideo${filter}+bestaudio/best${filter}`; - if (c.ext === 'mp4') { - selector = `bestvideo${filter}[ext=mp4]+bestaudio[ext=m4a]/best${filter}[ext=mp4]/bestvideo${filter}+bestaudio/best${filter}`; - } else if (c.ext === 'webm') { - selector = `bestvideo${filter}[ext=webm]+bestaudio[ext=webm]/best${filter}[ext=webm]/bestvideo${filter}+bestaudio/best${filter}`; - } - - options.push({ - name: `${q.name} ${c.name}`, - selector, - ext: c.ext, - formatLabel: `${c.name.toUpperCase()} • Video + audio`, - detail: est ? `~${formatBytes(est)}` : '', - type: 'Video', - bytes: est || 0 - }); - } - } - - if (hasAudioFormat(rawFormats, null)) { - const est = estimatedAudioBytes(rawFormats, null); - options.push({ - name: "Audio MP3", - selector: "bestaudio/best", - ext: "mp3", - formatLabel: "MP3 • Best audio", - detail: est ? `~${formatBytes(est)}` : '', - type: 'Audio', - bytes: est || 0 - }); - } - - if (hasAudioFormat(rawFormats, "m4a")) { - const est = estimatedAudioBytes(rawFormats, "m4a"); - options.push({ - name: "Audio M4A", - selector: "bestaudio[ext=m4a]/bestaudio/best", - ext: "m4a", - formatLabel: "M4A • AAC", - detail: est ? `~${formatBytes(est)}` : '', - type: 'Audio', - bytes: est || 0 - }); - } - - if (hasAudioFormat(rawFormats, "webm") || hasAudioFormat(rawFormats, "opus")) { - const est = estimatedAudioBytes(rawFormats, "webm") || estimatedAudioBytes(rawFormats, "opus"); - options.push({ - name: "Audio Opus", - selector: "bestaudio[ext=webm]/bestaudio/best", - ext: "opus", - formatLabel: "OPUS • Opus", - detail: est ? `~${formatBytes(est)}` : '', - type: 'Audio', - bytes: est || 0 - }); - } - - return { title, formats: options }; - } catch (e) { - return null; - } -}; - export const AddDownloadsModal = () => { const { isAddModalOpen, @@ -416,7 +200,7 @@ export const AddDownloadsModal = () => { }); if (mediaData && mediaData.formats.length > 0) { const mappedFormats = mediaData.formats.map(f => { - const quality = f.resolution || 'Best'; + const quality = f.resolution || 'Video'; const container = f.ext.toUpperCase(); const exactBytes = f.filesize || 0; const approxBytes = f.filesize_approx || 0; diff --git a/src/components/QualityModal.tsx b/src/components/QualityModal.tsx index 57e732d..c357aad 100644 --- a/src/components/QualityModal.tsx +++ b/src/components/QualityModal.tsx @@ -97,7 +97,7 @@ export const QualityModal = React.memo(() => { > {activeMetadata.formats.map(f => ( ))}