diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 24f147c..113620c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, + #[ts(type = "number | null")] + pub filesize: Option, +} + +#[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, + pub thumbnail: Option, + pub formats: Vec, +} + @@ -163,14 +185,13 @@ async fn fetch_metadata(url: String, user_agent: Option, username: Optio } #[tauri::command] -async fn fetch_media_metadata(app_handle: tauri::AppHandle, url: String, cookie_browser: Option, username: Option, password: Option) -> Result { +async fn fetch_media_metadata(app_handle: tauri::AppHandle, url: String, cookie_browser: Option, username: Option, password: Option) -> Result { 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)) diff --git a/src/App.tsx b/src/App.tsx index d2e0f4f..9f50db8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { + ); diff --git a/src/bindings/MediaFormat.ts b/src/bindings/MediaFormat.ts new file mode 100644 index 0000000..dae469d --- /dev/null +++ b/src/bindings/MediaFormat.ts @@ -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, }; diff --git a/src/bindings/MediaMetadata.ts b/src/bindings/MediaMetadata.ts new file mode 100644 index 0000000..fd20e27 --- /dev/null +++ b/src/bindings/MediaMetadata.ts @@ -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, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 4e1b0f3..a712442 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -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 { diff --git a/src/components/QualityModal.tsx b/src/components/QualityModal.tsx new file mode 100644 index 0000000..c140dc7 --- /dev/null +++ b/src/components/QualityModal.tsx @@ -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(''); + + 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 ( +
+
+

Media Quality Selection

+ + {isParsing && ( +
+
+

Parsing media metadata...

+
+ )} + + {parsingError && ( +
+

Parsing Failed

+

{parsingError}

+
+ +
+
+ )} + + {activeMetadata && ( + <> +
+

{activeMetadata.title}

+ {activeMetadata.duration &&

Duration: {Math.floor(activeMetadata.duration / 60)}:{String(activeMetadata.duration % 60).padStart(2, '0')}

} +
+ +
+ + +
+ +
+ + +
+ + )} +
+
+ ); +}); diff --git a/src/ipc.ts b/src/ipc.ts index c901510..f2d94fd 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -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 }; diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index f89e8eb..13fb23e 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -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; + + isParsing: boolean; + activeMetadata: MediaMetadata | null; + activeMetadataUrl: string | null; + parsingError: string | null; + fetchMetadataAction: (url: string) => Promise; + clearMetadata: () => void; } let isProcessingQueue = false; @@ -110,6 +118,10 @@ export const useDownloadStore = create((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((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] }));