From e86bc867938af3dd6446b0da4c8cf9f88fc7cf17 Mon Sep 17 00:00:00 2001 From: Julian Freeman Date: Sun, 19 Apr 2026 09:56:09 -0400 Subject: [PATCH] op 1 --- src-tauri/.gitignore | 1 + src-tauri/src/binary_manager.rs | 45 +- src-tauri/src/commands.rs | 66 ++- src-tauri/src/downloader.rs | 108 +++-- src-tauri/src/lib.rs | 1 + src-tauri/src/process_utils.rs | 10 + src-tauri/src/storage.rs | 28 +- src-tauri/tauri.conf.json | 2 +- src/App.vue | 11 +- src/stores/analysis.ts | 18 +- src/stores/logs.ts | 11 +- src/stores/queue.ts | 11 +- src/stores/settings.ts | 53 ++- src/types/media.ts | 24 ++ src/utils/analysis.ts | 45 ++ src/views/History.vue | 5 +- src/views/Home.vue | 742 +++++++++++++++----------------- src/views/Settings.vue | 4 +- 18 files changed, 685 insertions(+), 500 deletions(-) create mode 100644 src-tauri/src/process_utils.rs create mode 100644 src/types/media.ts create mode 100644 src/utils/analysis.ts diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index b21bd68..5467e21 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -1,6 +1,7 @@ # Generated by Cargo # will have compiled files and executables /target/ +/target*/ # Generated by Tauri # will have schema files for capabilities auto-completion diff --git a/src-tauri/src/binary_manager.rs b/src-tauri/src/binary_manager.rs index 8ee1ad6..8c693cd 100644 --- a/src-tauri/src/binary_manager.rs +++ b/src-tauri/src/binary_manager.rs @@ -10,6 +10,7 @@ use std::os::windows::process::CommandExt; use zip::ZipArchive; use std::io::Cursor; +use crate::process_utils::first_non_empty_line; use crate::storage::{self}; const YT_DLP_REPO_URL: &str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download"; @@ -285,6 +286,11 @@ pub async fn download_qjs(app: &AppHandle) -> Result { pub async fn update_qjs(app: &AppHandle) -> Result { // QuickJS doesn't have self-update, so we just re-download download_qjs(app).await?; + + let mut settings = storage::load_settings(app)?; + settings.last_updated = Some(chrono::Utc::now()); + storage::save_settings(app, &settings)?; + Ok("QuickJS 已更新/安装".to_string()) } @@ -441,18 +447,8 @@ pub fn get_ffmpeg_version(app: &AppHandle) -> Result { let output = cmd.output()?; if output.status.success() { - // Prefer stdout, fallback to stderr if stdout empty - let out = if !output.stdout.is_empty() { - String::from_utf8_lossy(&output.stdout).to_string() - } else { - String::from_utf8_lossy(&output.stderr).to_string() - }; - - if let Some(first_line) = out.lines().next() { - let v = first_line.trim().to_string(); - if !v.is_empty() { - return Ok(v); - } + if let Some(line) = first_non_empty_line(&output) { + return Ok(line); } } @@ -465,6 +461,31 @@ pub fn get_qjs_version(app: &AppHandle) -> Result { if !path.exists() { return Ok("未安装".to_string()); } + + let mut version_cmd = std::process::Command::new(&path); + version_cmd.arg("--version"); + #[cfg(target_os = "windows")] + version_cmd.creation_flags(0x08000000); + + if let Ok(output) = version_cmd.output() { + if output.status.success() { + if let Some(line) = first_non_empty_line(&output) { + return Ok(line); + } + } + } + + let mut help_cmd = std::process::Command::new(&path); + help_cmd.arg("-h"); + #[cfg(target_os = "windows")] + help_cmd.creation_flags(0x08000000); + + if let Ok(output) = help_cmd.output() { + if let Some(line) = first_non_empty_line(&output) { + return Ok(line); + } + } + Ok("已安装".to_string()) } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 321b501..94078a7 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -5,6 +5,10 @@ use crate::downloader::DownloadOptions; use crate::storage::{Settings, HistoryItem}; use uuid::Uuid; use std::path::Path; +use std::sync::LazyLock; +use tokio::sync::Semaphore; + +static DOWNLOAD_SEMAPHORE: LazyLock = LazyLock::new(|| Semaphore::new(3)); #[tauri::command] pub async fn init_ytdlp(app: AppHandle) -> Result { @@ -60,14 +64,29 @@ pub async fn fetch_image(url: String) -> Result { .await .map_err(|e| e.to_string())?; + if !res.status().is_success() { + return Err(format!("image fetch failed with status {}", res.status())); + } + + let mime = res + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.split(';').next().unwrap_or("image/jpeg").to_string()) + .unwrap_or_else(|| { + if url.to_lowercase().ends_with(".png") { + "image/png".to_string() + } else if url.to_lowercase().ends_with(".webp") { + "image/webp".to_string() + } else { + "image/jpeg".to_string() + } + }); let bytes = res.bytes().await.map_err(|e| e.to_string())?; // Convert to base64 let b64 = general_purpose::STANDARD.encode(&bytes); - - // Simple heuristic for mime type - let mime = if url.to_lowercase().ends_with(".png") { "image/png" } else { "image/jpeg" }; - + Ok(format!("data:{};base64,{}", mime, b64)) } @@ -84,9 +103,11 @@ pub async fn start_download(app: AppHandle, url: String, options: DownloadOption // Spawn the download task tauri::async_runtime::spawn(async move { + let _permit = DOWNLOAD_SEMAPHORE.acquire().await.ok(); let res = downloader::download_video(app.clone(), id_clone.clone(), url.clone(), options.clone()).await; let status = if res.is_ok() { "success" } else { "failed" }; + let file_path = res.ok().flatten(); // Add to history let output_dir = options.output_path.clone(); // Store the directory user selected @@ -97,6 +118,7 @@ pub async fn start_download(app: AppHandle, url: String, options: DownloadOption thumbnail: metadata.thumbnail, url: url, output_path: output_dir, + file_path, timestamp: chrono::Utc::now(), status: status.to_string(), format: options.output_format, @@ -136,16 +158,16 @@ pub fn delete_history_item(app: AppHandle, id: String) -> Result<(), String> { #[tauri::command] pub async fn close_splash(app: AppHandle) { if let Some(splash) = app.get_webview_window("splashscreen") { - splash.close().unwrap(); + let _ = splash.close(); } if let Some(main) = app.get_webview_window("main") { - main.show().unwrap(); + let _ = main.show(); } } #[tauri::command] pub fn open_in_explorer(app: AppHandle, path: String) -> Result<(), String> { - let path_to_open = if Path::new(&path).exists() { + let resolved_path = if Path::new(&path).exists() { path } else { app.path().download_dir() @@ -155,17 +177,29 @@ pub fn open_in_explorer(app: AppHandle, path: String) -> Result<(), String> { #[cfg(target_os = "windows")] { - std::process::Command::new("explorer") - .arg(path_to_open) - .spawn() - .map_err(|e| e.to_string())?; + let resolved = Path::new(&resolved_path); + let mut command = std::process::Command::new("explorer"); + + if resolved.is_file() { + command.arg("/select,").arg(resolved); + } else { + command.arg(resolved); + } + + command.spawn().map_err(|e| e.to_string())?; } #[cfg(target_os = "macos")] { - std::process::Command::new("open") - .arg(path_to_open) - .spawn() - .map_err(|e| e.to_string())?; + let resolved = Path::new(&resolved_path); + let mut command = std::process::Command::new("open"); + + if resolved.is_file() { + command.arg("-R").arg(resolved); + } else { + command.arg(resolved); + } + + command.spawn().map_err(|e| e.to_string())?; } Ok(()) -} \ No newline at end of file +} diff --git a/src-tauri/src/downloader.rs b/src-tauri/src/downloader.rs index 9a358a9..68554bc 100644 --- a/src-tauri/src/downloader.rs +++ b/src-tauri/src/downloader.rs @@ -57,6 +57,7 @@ pub struct LogEvent { pub level: String, // "info", "error" } +const FINAL_PATH_MARKER: &str = "__STREAM_CAPTURE_FINAL_PATH__"; pub async fn fetch_metadata(app: &AppHandle, url: &str, parse_mix_playlist: bool) -> Result { @@ -218,7 +219,7 @@ pub async fn download_video( id: String, // Unique ID for this download task (provided by frontend) url: String, options: DownloadOptions, -) -> Result { +) -> Result> { let ytdlp_path = binary_manager::get_ytdlp_path(&app)?; let qjs_path = binary_manager::get_qjs_path(&app)?; // Get absolute path to quickjs let ffmpeg_path = binary_manager::get_ffmpeg_path(&app)?; // Get absolute path to ffmpeg @@ -246,6 +247,8 @@ pub async fn download_video( let output_template = format!("{}/%(title)s.%(ext)s", options.output_path.trim_end_matches(std::path::MAIN_SEPARATOR)); args.push("-o".to_string()); args.push(output_template); + args.push("--print".to_string()); + args.push(format!("after_move:{FINAL_PATH_MARKER}%(filepath)s")); // Formats if options.is_audio_only { @@ -295,56 +298,84 @@ pub async fn download_video( let stdout = child.stdout.take().ok_or(anyhow!("Failed to open stdout"))?; let stderr = child.stderr.take().ok_or(anyhow!("Failed to open stderr"))?; - let mut stdout_reader = BufReader::new(stdout); - let mut stderr_reader = BufReader::new(stderr); + let progress_regex = Regex::new(r"\[download\]\s+(\d+(?:\.\d+)?)%.*?(?:\s+at\s+([^\s]+))?").unwrap(); - let re = Regex::new(r"\[download\]\s+(\d+\.?\d*)%").unwrap(); + let stdout_task = { + let app = app.clone(); + let id = id.clone(); + tokio::spawn(async move { + let mut reader = BufReader::new(stdout).lines(); + let mut final_path: Option = None; - // Loop to read both streams - loop { - let mut out_line = String::new(); - let mut err_line = String::new(); - - tokio::select! { - res = stdout_reader.read_line(&mut out_line) => { - if res.unwrap_or(0) == 0 { - break; // EOF + while let Some(line) = reader.next_line().await? { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; } - - // Parse progress - if let Some(caps) = re.captures(&out_line) { + + if let Some(path) = trimmed.strip_prefix(FINAL_PATH_MARKER) { + final_path = Some(path.to_string()); + continue; + } + + if let Some(caps) = progress_regex.captures(trimmed) { if let Some(pct_match) = caps.get(1) { if let Ok(pct) = pct_match.as_str().parse::() { + let speed = caps + .get(2) + .map(|value| value.as_str().to_string()) + .unwrap_or_else(|| "待定".to_string()); + app.emit("download-progress", ProgressEvent { id: id.clone(), progress: pct, - speed: "待定".to_string(), + speed, status: "downloading".to_string(), }).ok(); + continue; } } - } else { // Only emit download-log if it's NOT a progress line - app.emit("download-log", LogEvent { - id: id.clone(), - message: out_line.trim().to_string(), - level: "info".to_string(), - }).ok(); } + + app.emit("download-log", LogEvent { + id: id.clone(), + message: trimmed.to_string(), + level: "info".to_string(), + }).ok(); } - res = stderr_reader.read_line(&mut err_line) => { - if res.unwrap_or(0) > 0 { - // Log error - app.emit("download-log", LogEvent { - id: id.clone(), - message: err_line.trim().to_string(), - level: "error".to_string(), - }).ok(); - } + + Ok::, anyhow::Error>(final_path) + }) + }; + + let stderr_task = { + let app = app.clone(); + let id = id.clone(); + tokio::spawn(async move { + let mut reader = BufReader::new(stderr).lines(); + let mut last_error: Option = None; + + while let Some(line) = reader.next_line().await? { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + last_error = Some(trimmed.to_string()); + app.emit("download-log", LogEvent { + id: id.clone(), + message: trimmed.to_string(), + level: "error".to_string(), + }).ok(); } - } - } + + Ok::, anyhow::Error>(last_error) + }) + }; let status = child.wait().await?; + let final_path = stdout_task.await.map_err(|e| anyhow!(e.to_string()))??; + let last_error = stderr_task.await.map_err(|e| anyhow!(e.to_string()))??; if status.success() { app.emit("download-progress", ProgressEvent { @@ -353,7 +384,7 @@ pub async fn download_video( speed: "-".to_string(), status: "finished".to_string(), }).ok(); - Ok("下载完成".to_string()) + Ok(final_path) } else { app.emit("download-progress", ProgressEvent { id: id.clone(), @@ -361,6 +392,11 @@ pub async fn download_video( speed: "-".to_string(), status: "error".to_string(), }).ok(); - Err(anyhow!("下载进程失败")) + Err(anyhow!( + "下载进程失败{}", + last_error + .map(|message| format!(": {message}")) + .unwrap_or_default() + )) } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6d567cd..30e8c35 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod binary_manager; mod downloader; mod storage; mod commands; +mod process_utils; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { diff --git a/src-tauri/src/process_utils.rs b/src-tauri/src/process_utils.rs new file mode 100644 index 0000000..8f26007 --- /dev/null +++ b/src-tauri/src/process_utils.rs @@ -0,0 +1,10 @@ +use std::process::Output; + +pub fn first_non_empty_line(output: &Output) -> Option { + String::from_utf8_lossy(&output.stdout) + .lines() + .chain(String::from_utf8_lossy(&output.stderr).lines()) + .map(str::trim) + .find(|line| !line.is_empty()) + .map(str::to_string) +} diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index a288676..4170792 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -34,11 +34,35 @@ pub struct HistoryItem { pub thumbnail: String, pub url: String, pub output_path: String, + #[serde(default)] + pub file_path: Option, pub timestamp: DateTime, pub status: String, // "success", "failed" pub format: String, } +fn write_json_atomically(path: &PathBuf, content: &str) -> Result<()> { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("data.json"); + let tmp_path = path.with_file_name(format!("{file_name}.tmp")); + + fs::write(&tmp_path, content)?; + + if path.exists() { + match fs::rename(&tmp_path, path) { + Ok(()) => return Ok(()), + Err(_) => { + fs::remove_file(path)?; + } + } + } + + fs::rename(&tmp_path, path)?; + Ok(()) +} + pub fn get_app_data_dir(app: &AppHandle) -> Result { // In Tauri v2, we use app.path().app_data_dir() let path = app.path().app_data_dir()?; @@ -76,7 +100,7 @@ pub fn load_settings(app: &AppHandle) -> Result { pub fn save_settings(app: &AppHandle, settings: &Settings) -> Result<()> { let path = get_settings_path(app)?; let content = serde_json::to_string_pretty(settings)?; - fs::write(path, content)?; + write_json_atomically(&path, &content)?; Ok(()) } @@ -94,7 +118,7 @@ pub fn load_history(app: &AppHandle) -> Result> { pub fn save_history(app: &AppHandle, history: &[HistoryItem]) -> Result<()> { let path = get_history_path(app)?; let content = serde_json::to_string_pretty(history)?; - fs::write(path, content)?; + write_json_atomically(&path, &content)?; Ok(()) } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index db14984..cbad0bf 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -31,7 +31,7 @@ } ], "security": { - "csp": null + "csp": "default-src 'self' asset: http://asset.localhost https://asset.localhost; img-src 'self' asset: http://asset.localhost https://asset.localhost data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; connect-src 'self' ipc: http://ipc.localhost https://ipc.localhost ws://localhost:1420 http://localhost:1420 https:; font-src 'self' asset: http://asset.localhost https://asset.localhost data:;" } }, "bundle": { diff --git a/src/App.vue b/src/App.vue index fe89bd7..9a1fd2f 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,5 +1,5 @@ \ No newline at end of file + diff --git a/src/stores/analysis.ts b/src/stores/analysis.ts index 6482fba..1d58e25 100644 --- a/src/stores/analysis.ts +++ b/src/stores/analysis.ts @@ -1,12 +1,14 @@ // filepath: src/stores/analysis.ts import { defineStore } from 'pinia' import { ref } from 'vue' +import type { AnalysisMetadata } from '../types/media' +import { isPlaylistMetadata } from '../types/media' export const useAnalysisStore = defineStore('analysis', () => { const url = ref('') const loading = ref(false) const error = ref('') - const metadata = ref(null) + const metadata = ref(null) // New state for mix detection const isMix = ref(false) @@ -24,8 +26,8 @@ export const useAnalysisStore = defineStore('analysis', () => { }) function toggleEntry(id: string) { - if (metadata.value && metadata.value.entries) { - const entry = metadata.value.entries.find((e: any) => e.id === id) + if (isPlaylistMetadata(metadata.value)) { + const entry = metadata.value.entries.find(e => e.id === id) if (entry) { entry.selected = !entry.selected } @@ -33,8 +35,8 @@ export const useAnalysisStore = defineStore('analysis', () => { } function setAllEntries(selected: boolean) { - if (metadata.value && metadata.value.entries) { - metadata.value.entries = metadata.value.entries.map((e: any) => ({ + if (isPlaylistMetadata(metadata.value)) { + metadata.value.entries = metadata.value.entries.map(e => ({ ...e, selected })) @@ -42,8 +44,8 @@ export const useAnalysisStore = defineStore('analysis', () => { } function invertSelection() { - if (metadata.value && metadata.value.entries) { - metadata.value.entries = metadata.value.entries.map((e: any) => ({ + if (isPlaylistMetadata(metadata.value)) { + metadata.value.entries = metadata.value.entries.map(e => ({ ...e, selected: !e.selected })) @@ -60,4 +62,4 @@ export const useAnalysisStore = defineStore('analysis', () => { } return { url, loading, error, metadata, options, isMix, scanMix, isBatchMode, toggleEntry, setAllEntries, invertSelection, reset } -}) \ No newline at end of file +}) diff --git a/src/stores/logs.ts b/src/stores/logs.ts index f079df0..9e76d0a 100644 --- a/src/stores/logs.ts +++ b/src/stores/logs.ts @@ -20,6 +20,7 @@ interface LogEvent { export const useLogsStore = defineStore('logs', () => { const logs = ref([]) const isListening = ref(false) + let unlisten: (() => void) | null = null function addLog(taskId: string, message: string, level: 'info' | 'error') { logs.value.push({ @@ -40,7 +41,7 @@ export const useLogsStore = defineStore('logs', () => { if (isListening.value) return isListening.value = true - await listen('download-log', (event) => { + unlisten = await listen('download-log', (event) => { const { id, message, level } = event.payload addLog(id, message, level as 'info' | 'error') }) @@ -54,5 +55,11 @@ export const useLogsStore = defineStore('logs', () => { const autoScroll = ref(true) const scrollTop = ref(0) - return { logs, addLog, initListener, clearLogs, autoScroll, scrollTop } + function disposeListener() { + unlisten?.() + unlisten = null + isListening.value = false + } + + return { logs, addLog, initListener, clearLogs, disposeListener, autoScroll, scrollTop } }) diff --git a/src/stores/queue.ts b/src/stores/queue.ts index 4e01d92..39499b8 100644 --- a/src/stores/queue.ts +++ b/src/stores/queue.ts @@ -22,6 +22,7 @@ interface ProgressEvent { export const useQueueStore = defineStore('queue', () => { const tasks = ref([]) const isListening = ref(false) + let unlisten: (() => void) | null = null function addTask(task: DownloadTask) { tasks.value.push(task) @@ -31,7 +32,7 @@ export const useQueueStore = defineStore('queue', () => { if (isListening.value) return isListening.value = true - await listen('download-progress', (event) => { + unlisten = await listen('download-progress', (event) => { const { id, progress, speed, status } = event.payload const task = tasks.value.find(t => t.id === id) if (task) { @@ -43,5 +44,11 @@ export const useQueueStore = defineStore('queue', () => { }) } - return { tasks, addTask, initListener } + function disposeListener() { + unlisten?.() + unlisten = null + isListening.value = false + } + + return { tasks, addTask, initListener, disposeListener } }) diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 54c41f1..911a31c 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -22,6 +22,9 @@ export const useSettingsStore = defineStore('settings', () => { const quickjsVersion = ref('Checking...') const ffmpegVersion = ref('Checking...') const isInitializing = ref(true) + const hasInitialized = ref(false) + let mediaQuery: MediaQueryList | null = null + let mediaListener: (() => void) | null = null async function loadSettings() { try { @@ -43,11 +46,13 @@ export const useSettingsStore = defineStore('settings', () => { } async function initYtdlp() { + if (hasInitialized.value) return try { isInitializing.value = true // check/download await invoke('init_ytdlp') await refreshVersions() + hasInitialized.value = true } catch (e) { console.error(e) ytdlpVersion.value = 'Error' @@ -59,9 +64,15 @@ export const useSettingsStore = defineStore('settings', () => { } async function refreshVersions() { - ytdlpVersion.value = await invoke('get_ytdlp_version') - quickjsVersion.value = await invoke('get_quickjs_version') - ffmpegVersion.value = await invoke('get_ffmpeg_version') + const [ytdlp, quickjs, ffmpeg] = await Promise.allSettled([ + invoke('get_ytdlp_version'), + invoke('get_quickjs_version'), + invoke('get_ffmpeg_version') + ]) + + ytdlpVersion.value = ytdlp.status === 'fulfilled' ? ytdlp.value : 'Error' + quickjsVersion.value = quickjs.status === 'fulfilled' ? quickjs.value : 'Error' + ffmpegVersion.value = ffmpeg.status === 'fulfilled' ? ffmpeg.value : 'Error' } function applyTheme(theme: string) { @@ -74,12 +85,36 @@ export const useSettingsStore = defineStore('settings', () => { } } - // Watch system preference changes if theme is system - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + function initThemeListener() { + if (mediaQuery) return + mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + mediaListener = () => { if (settings.value.theme === 'system') { - applyTheme('system') + applyTheme('system') } - }) + } + mediaQuery.addEventListener('change', mediaListener) + } - return { settings, loadSettings, save, initYtdlp, refreshVersions, ytdlpVersion, quickjsVersion, ffmpegVersion, isInitializing } -}) \ No newline at end of file + function disposeThemeListener() { + if (mediaQuery && mediaListener) { + mediaQuery.removeEventListener('change', mediaListener) + } + mediaQuery = null + mediaListener = null + } + + return { + settings, + loadSettings, + save, + initYtdlp, + refreshVersions, + initThemeListener, + disposeThemeListener, + ytdlpVersion, + quickjsVersion, + ffmpegVersion, + isInitializing + } +}) diff --git a/src/types/media.ts b/src/types/media.ts new file mode 100644 index 0000000..a08652d --- /dev/null +++ b/src/types/media.ts @@ -0,0 +1,24 @@ +export interface VideoMetadata { + id: string + title: string + thumbnail: string + duration?: number | null + uploader?: string | null + url?: string | null +} + +export interface SelectableVideoMetadata extends VideoMetadata { + selected: boolean +} + +export interface PlaylistMetadata { + id: string + title: string + entries: SelectableVideoMetadata[] +} + +export type AnalysisMetadata = VideoMetadata | PlaylistMetadata + +export function isPlaylistMetadata(metadata: AnalysisMetadata | null): metadata is PlaylistMetadata { + return !!metadata && Array.isArray((metadata as PlaylistMetadata).entries) +} diff --git a/src/utils/analysis.ts b/src/utils/analysis.ts new file mode 100644 index 0000000..dad8294 --- /dev/null +++ b/src/utils/analysis.ts @@ -0,0 +1,45 @@ +import type { PlaylistMetadata, SelectableVideoMetadata, VideoMetadata } from '../types/media' + +export function detectMixUrl(url: string): boolean { + return url.includes('v=') && url.includes('list=') +} + +export function stripPlaylistContext(rawUrl: string): string { + try { + const url = new URL(rawUrl) + url.searchParams.delete('list') + url.searchParams.delete('index') + url.searchParams.delete('start_radio') + return url.toString() + } catch { + return rawUrl + .replace(/[?&]list=[^&]+/g, '') + .replace(/[?&]index=[^&]+/g, '') + .replace(/[?&]start_radio=[^&]+/g, '') + .replace('?&', '?') + .replace(/&&+/g, '&') + .replace(/[?&]$/, '') + } +} + +export function isLikelyHttpUrl(input: string): boolean { + return input.startsWith('http://') || input.startsWith('https://') +} + +export function normalizeBatchLinks(input: string): string[] { + return input + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + .filter(isLikelyHttpUrl) + .filter(link => !link.includes('list=') || detectMixUrl(link)) + .map(link => detectMixUrl(link) ? stripPlaylistContext(link) : link) +} + +export function toSelectableEntries(entries: VideoMetadata[]): SelectableVideoMetadata[] { + return entries.map(entry => ({ ...entry, selected: true })) +} + +export function countSelectedEntries(metadata: PlaylistMetadata | null): number { + return metadata?.entries.filter(entry => entry.selected).length ?? 0 +} diff --git a/src/views/History.vue b/src/views/History.vue index d258373..c4de11b 100644 --- a/src/views/History.vue +++ b/src/views/History.vue @@ -11,6 +11,7 @@ interface HistoryItem { thumbnail: string url: string output_path: string + file_path?: string | null timestamp: string status: string format: string @@ -114,7 +115,7 @@ onMounted(loadHistory) +
-
- - -
- - +
+ + +
+ +
- - +
- - 解析播放列表 (前20项) + + 解析播放列表 (前20项)

{{ analysisStore.error }}

-
- - -
-
-
-

{{ analysisStore.metadata.title }}

-

{{ analysisStore.metadata.entries.length }} 个视频

-
+
+
+
+

{{ playlistMetadata.title }}

+

{{ playlistMetadata.entries.length }} 个视频

- - -
- - -
- - - -
+
- -
- -
- 仅音频 - -
- - -
-
- -
-
- - -
-
- -
-
-
+
+
+ + +
-
- -
-
- - +
- - - - -
-

{{ entry.title }}

-

- {{ entry.duration ? Math.floor(entry.duration / 60) + ':' + String(Math.floor(entry.duration % 60)).padStart(2, '0') : '' }} - {{ entry.uploader }} -

-
-
-
- - -
- - - - -
-

{{ analysisStore.metadata.title }}

-

{{ analysisStore.metadata.uploader }}

-

{{ analysisStore.metadata.entries.length }} 个视频 (播放列表)

- - -
- -
- 仅音频 - -
- - -
- +
+ -
+
+
- -
- +
+ -
+
+
+
+
+
+ +
+
+ + + + +
+

{{ entry.title }}

+

+ {{ entry.duration ? Math.floor(entry.duration / 60) + ':' + String(Math.floor(entry.duration % 60)).padStart(2, '0') : '' }} + {{ entry.uploader }} +

+
+
+
+ +
+ + +
+

{{ singleMetadata.title }}

+

{{ singleMetadata.uploader }}

+ +
+
+ 仅音频 + +
+ +
+ +
+ +
+ +
- +
-
-

进行中的任务

-
- -
- -
-
-

{{ task.title }}

- - {{ task.status === 'finished' ? '已完成' : (task.status === 'error' ? '失败' : task.speed) }} - -
-
-
-
-
+

进行中的任务

+
+
+ +
+
+

{{ task.title }}

+ + {{ task.status === 'finished' ? '已完成' : (task.status === 'error' ? '失败' : task.speed) }} + +
+
+
+
-
+
+
-
- \ No newline at end of file + diff --git a/src/views/Settings.vue b/src/views/Settings.vue index c9607bc..792e68b 100644 --- a/src/views/Settings.vue +++ b/src/views/Settings.vue @@ -210,7 +210,7 @@ function setTheme(theme: 'light' | 'dark' | 'system') {
QuickJS
-
{{ settingsStore.quickjsVersion }}
+
{{ settingsStore.quickjsVersion }}
- \ No newline at end of file +