mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
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.
This commit is contained in:
+102
-70
@@ -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<f64>,
|
||||
requested_formats: Option<&[serde_json::Value]>,
|
||||
) -> Vec<MediaFormat> {
|
||||
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<u64> = [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<MediaProgress> {
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -97,7 +97,7 @@ export const QualityModal = React.memo(() => {
|
||||
>
|
||||
{activeMetadata.formats.map(f => (
|
||||
<option key={f.format_id} value={f.format_id}>
|
||||
{f.resolution || 'Best'} • {f.format_label || f.ext.toUpperCase()} • {f.filesize ? formatBytes(f.filesize) : 'Unknown size'}
|
||||
{f.resolution || 'Video'} • {f.format_label || f.ext.toUpperCase()} • {f.filesize || f.filesize_approx ? `${f.filesize ? '' : '~'}${formatBytes(f.filesize || f.filesize_approx || 0)}` : 'Unknown size'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
Reference in New Issue
Block a user