feat: implement pre-download media parsing engine and quality selection modal

This commit is contained in:
NimBold
2026-06-16 15:39:14 +03:30
parent 4027ac39ab
commit e41e761b33
8 changed files with 240 additions and 21 deletions
+46 -6
View File
@@ -17,6 +17,28 @@ pub struct MetadataResponse {
size_bytes: u64,
}
#[derive(Debug, Serialize, serde::Deserialize, Clone, TS)]
#[ts(export, export_to = "../../src/bindings/")]
pub struct MediaFormat {
pub format_id: String,
pub resolution: String,
pub ext: String,
#[ts(type = "number | null")]
pub fps: Option<f64>,
#[ts(type = "number | null")]
pub filesize: Option<u64>,
}
#[derive(Debug, Serialize, serde::Deserialize, Clone, TS)]
#[ts(export, export_to = "../../src/bindings/")]
pub struct MediaMetadata {
pub title: String,
#[ts(type = "number | null")]
pub duration: Option<u64>,
pub thumbnail: Option<String>,
pub formats: Vec<MediaFormat>,
}
@@ -163,14 +185,13 @@ async fn fetch_metadata(url: String, user_agent: Option<String>, username: Optio
}
#[tauri::command]
async fn fetch_media_metadata(app_handle: tauri::AppHandle, url: String, cookie_browser: Option<String>, username: Option<String>, password: Option<String>) -> Result<String, String> {
async fn fetch_media_metadata(app_handle: tauri::AppHandle, url: String, cookie_browser: Option<String>, username: Option<String>, password: Option<String>) -> Result<MediaMetadata, String> {
println!("fetch_media_metadata called for: {}", url);
use tauri_plugin_shell::ShellExt;
let mut cmd = app_handle.shell().sidecar("yt-dlp").map_err(|e| format!("Failed to create sidecar yt-dlp: {}", e))?;
cmd = cmd.arg("-J")
cmd = cmd.arg("--dump-json")
.arg("--no-warnings")
.arg("--no-playlist")
.arg("--no-check-formats")
.arg("--socket-timeout").arg("20")
.arg("--retries").arg("3")
.arg("--extractor-retries").arg("3")
@@ -203,14 +224,33 @@ async fn fetch_media_metadata(app_handle: tauri::AppHandle, url: String, cookie_
cmd = cmd.arg("--").arg(&url);
// We use tokio AsyncCommand so it doesn't block the async thread
let output = cmd.output()
.await
.map_err(|e| format!("Failed to execute yt-dlp: {}", e))?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout).to_string();
Ok(text)
let value: serde_json::Value = serde_json::from_slice(&output.stdout).map_err(|e| format!("Failed to parse JSON: {}", e))?;
let title = value.get("title").and_then(|v| v.as_str()).unwrap_or("Unknown Title").to_string();
let duration = value.get("duration").and_then(|v| v.as_f64()).map(|v| v as u64);
let thumbnail = value.get("thumbnail").and_then(|v| v.as_str()).map(|s| s.to_string());
let mut formats = Vec::new();
if let Some(formats_arr) = value.get("formats").and_then(|v| v.as_array()) {
for fmt in formats_arr {
let format_id = fmt.get("format_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
let resolution = fmt.get("resolution").and_then(|v| v.as_str()).unwrap_or("audio only").to_string();
let ext = fmt.get("ext").and_then(|v| v.as_str()).unwrap_or("").to_string();
let fps = fmt.get("fps").and_then(|v| v.as_f64());
let filesize = fmt.get("filesize").and_then(|v| v.as_u64()).or_else(|| fmt.get("filesize_approx").and_then(|v| v.as_f64().map(|f| f as u64)));
if !format_id.is_empty() {
formats.push(MediaFormat { format_id, resolution, ext, fps, filesize });
}
}
}
Ok(MediaMetadata { title, duration, thumbnail, formats })
} else {
let err = String::from_utf8_lossy(&output.stderr);
Err(format!("yt-dlp error: {}", err))
+2
View File
@@ -5,6 +5,7 @@ import { DownloadTable } from "./components/DownloadTable";
import { AddDownloadsModal } from "./components/AddDownloadsModal";
import SettingsView from "./components/SettingsView";
import { PropertiesModal } from "./components/PropertiesModal";
import { QualityModal } from './components/QualityModal';
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal";
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore';
@@ -298,6 +299,7 @@ function App() {
<AddDownloadsModal />
<PropertiesModal />
<QualityModal />
<DeleteConfirmationModal />
</div>
);
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type MediaFormat = { format_id: string, resolution: string, ext: string, fps: number | null, filesize: number | null, };
+4
View File
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { MediaFormat } from "./MediaFormat";
export type MediaMetadata = { title: string, duration: number | null, thumbnail: string | null, formats: Array<MediaFormat>, };
+29 -14
View File
@@ -141,6 +141,22 @@ 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);
@@ -154,15 +170,6 @@ const parseMediaFormats = (jsonStr: string) => {
const options = [];
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" }
];
const availableResolutions = standardResolutions.filter(res =>
rawFormats.some(f => isVideo(f) && matchesHeight(f, res.h))
);
@@ -359,22 +366,30 @@ export const AddDownloadsModal = () => {
}
}
const jsonStr = await invoke('fetch_media_metadata', {
const mediaData = await invoke('fetch_media_metadata', {
url,
cookieBrowser: browserArg,
username: login?.username || null,
password: keychainPassword
});
const mediaData = parseMediaFormats(jsonStr);
if (mediaData && mediaData.formats.length > 0) {
const mappedFormats = mediaData.formats.map(f => ({
id: f.format_id,
name: f.resolution,
ext: f.ext,
bytes: f.filesize || 0,
detail: `${f.resolution}${f.ext}${f.fps ? f.fps + 'fps' : ''}${f.filesize ? formatBytes(f.filesize) : 'Unknown size'}`,
selector: f.format_id,
type: 'video'
}));
updatedItems[i] = {
url,
file: `${mediaData.title}.${mediaData.formats[0].ext}`,
size: mediaData.formats[0].detail || 'Unknown (Media)',
sizeBytes: mediaData.formats[0].bytes,
size: mappedFormats[0].detail,
sizeBytes: mappedFormats[0].bytes,
status: 'Ready',
isMedia: true,
formats: mediaData.formats,
formats: mappedFormats,
selectedFormat: 0
};
} else {
+124
View File
@@ -0,0 +1,124 @@
import React, { useState, useEffect } from 'react';
import { useDownloadStore } from '../store/useDownloadStore';
import { useSettingsStore } from '../store/useSettingsStore';
import { categoryForFileName } from '../utils/downloads';
const formatBytes = (bytes: number) => {
if (bytes === 0) return 'Unknown size';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
export const QualityModal = React.memo(() => {
const { activeMetadata, activeMetadataUrl, isParsing, parsingError, clearMetadata, addDownload } = useDownloadStore();
const [selectedFormatId, setSelectedFormatId] = useState<string>('');
useEffect(() => {
if (activeMetadata && activeMetadata.formats.length > 0 && !selectedFormatId) {
setSelectedFormatId(activeMetadata.formats[0].format_id);
}
}, [activeMetadata]);
if (!isParsing && !activeMetadata && !parsingError) return null;
const handleConfirm = () => {
if (!activeMetadata || !activeMetadataUrl || !selectedFormatId) return;
const format = activeMetadata.formats.find(f => f.format_id === selectedFormatId);
if (!format) return;
const settings = useSettingsStore.getState();
const id = crypto.randomUUID();
const filename = `${activeMetadata.title}.${format.ext}`.replace(/[\/\\?%*:|"<>]/g, '-');
const category = categoryForFileName(filename);
const destination = (settings.downloadDirectories && settings.downloadDirectories[category]) || settings.defaultDownloadPath || '~/Downloads';
const downloadItem = {
id,
url: activeMetadataUrl,
fileName: filename,
destination,
status: 'queued' as const,
fraction: 0,
size: format.filesize ? formatBytes(format.filesize) : 'Unknown',
speed: '-',
eta: '-',
dateAdded: new Date().toISOString(),
queueId: '00000000-0000-0000-0000-000000000001',
_dispatched: false,
isMedia: true,
selectedFormatId: format.format_id
};
addDownload(downloadItem as any);
clearMetadata();
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-[#1a1b1e] rounded-xl p-6 max-w-lg w-full shadow-2xl border border-gray-200 dark:border-gray-800">
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-gray-100">Media Quality Selection</h2>
{isParsing && (
<div className="py-8 flex flex-col items-center justify-center text-gray-500 dark:text-gray-400">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500 mb-4"></div>
<p>Parsing media metadata...</p>
</div>
)}
{parsingError && (
<div className="py-4 text-red-500 bg-red-50 dark:bg-red-900/20 p-4 rounded-lg">
<p className="font-semibold mb-1">Parsing Failed</p>
<p className="text-sm">{parsingError}</p>
<div className="mt-4 flex justify-end">
<button onClick={clearMetadata} className="px-4 py-2 border border-red-200 dark:border-red-800 rounded hover:bg-red-100 dark:hover:bg-red-900/40">Close</button>
</div>
</div>
)}
{activeMetadata && (
<>
<div className="mb-6">
<p className="font-semibold text-gray-900 dark:text-gray-100 mb-1 truncate">{activeMetadata.title}</p>
{activeMetadata.duration && <p className="text-sm text-gray-500">Duration: {Math.floor(activeMetadata.duration / 60)}:{String(activeMetadata.duration % 60).padStart(2, '0')}</p>}
</div>
<div className="mb-6">
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Select Format</label>
<select
className="w-full border rounded-lg p-3 bg-gray-50 dark:bg-[#25262b] border-gray-200 dark:border-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 outline-none transition-shadow"
value={selectedFormatId}
onChange={(e) => setSelectedFormatId(e.target.value)}
>
{activeMetadata.formats.map(f => (
<option key={f.format_id} value={f.format_id}>
{f.resolution} {f.ext.toUpperCase()} {f.fps ? `${f.fps}fps` : ''} {f.filesize ? `${formatBytes(f.filesize)}` : ''}
</option>
))}
</select>
</div>
<div className="flex justify-end gap-3 mt-8">
<button
onClick={clearMetadata}
className="px-5 py-2.5 text-sm font-medium border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={!selectedFormatId}
className="px-5 py-2.5 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Confirm Download
</button>
</div>
</>
)}
</div>
</div>
);
});
+2 -1
View File
@@ -6,6 +6,7 @@ import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
import type { DownloadStatus } from './bindings/DownloadStatus';
import type { ExtensionDownload } from './bindings/ExtensionDownload';
import type { MediaCookieSource } from './bindings/MediaCookieSource';
import type { MediaMetadata } from './bindings/MediaMetadata';
import type { MetadataResponse } from './bindings/MetadataResponse';
import type { PostQueueAction } from './bindings/PostQueueAction';
import type { ReleaseCheckOutcome } from './bindings/ReleaseCheckOutcome';
@@ -51,7 +52,7 @@ type CommandMap = {
};
fetch_media_metadata: {
args: { url: string; cookieBrowser: string | null; username: string | null; password: string | null };
result: string;
result: MediaMetadata;
};
test_ytdlp: { args: undefined; result: string };
test_aria2c: { args: undefined; result: string };
+30
View File
@@ -8,6 +8,7 @@ import type { DownloadItem } from '../bindings/DownloadItem';
import type { DownloadStatus } from '../bindings/DownloadStatus';
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
import type { Queue } from '../bindings/Queue';
import type { MediaMetadata } from '../bindings/MediaMetadata';
import { useSettingsStore } from './useSettingsStore';
export type { DownloadCategory } from '../utils/downloads';
@@ -98,6 +99,13 @@ interface DownloadState {
renameQueue: (id: string, name: string) => void;
removeQueue: (id: string) => void;
initDB: () => Promise<void>;
isParsing: boolean;
activeMetadata: MediaMetadata | null;
activeMetadataUrl: string | null;
parsingError: string | null;
fetchMetadataAction: (url: string) => Promise<void>;
clearMetadata: () => void;
}
let isProcessingQueue = false;
@@ -110,6 +118,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
pendingAddReferer: '',
pendingAddFilename: '',
selectedPropertiesDownloadId: null,
isParsing: false,
activeMetadata: null,
activeMetadataUrl: null,
parsingError: null,
deleteModalState: { isOpen: false },
openDeleteModal: (downloadId) => set({ deleteModalState: { isOpen: true, downloadId } }),
closeDeleteModal: () => set({ deleteModalState: { isOpen: false } }),
@@ -140,6 +152,24 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
);
},
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
clearMetadata: () => set({ isParsing: false, activeMetadata: null, activeMetadataUrl: null, parsingError: null }),
fetchMetadataAction: async (url) => {
set({ isParsing: true, parsingError: null, activeMetadata: null, activeMetadataUrl: url });
try {
const settings = useSettingsStore.getState();
const metadata = await invoke('fetch_media_metadata', {
url,
cookieBrowser: settings.mediaCookieSource === 'none' ? null : settings.mediaCookieSource,
username: null,
password: null
});
set({ isParsing: false, activeMetadata: metadata });
info(`Media metadata parsed for ${url}: found ${metadata.formats.length} formats`);
} catch (e) {
set({ isParsing: false, parsingError: String(e) });
info(`Media metadata parsing failed for ${url}: ${e}`);
}
},
addDownload: (item) => {
info(`Download ${item.id} added to queue`);
set((state) => ({ downloads: [...state.downloads, item] }));