mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-28 04:49:39 +00:00
feat: implement pre-download media parsing engine and quality selection modal
This commit is contained in:
@@ -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