mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
feat: implement pre-download media parsing engine and quality selection modal
This commit is contained in:
+46
-6
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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, };
|
||||
@@ -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>, };
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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] }));
|
||||
|
||||
Reference in New Issue
Block a user