import { Component, OnDestroy, OnInit } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, distinctUntilChanged, map } from 'rxjs'; import { Download, DownloadsService, LibraryFile, LibraryFolder, Status } from './downloads.service'; import { Formats, Format, Quality } from './formats'; import { Theme, Themes } from './theme'; import { PreferencesService } from './preferences.service'; import { ToastService } from './toast.service'; import { KeyedDownload } from './active-downloads.component'; export type DownloadTab = 'downloading' | 'queued' | 'completed'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.sass'], standalone: false }) export class AppComponent implements OnInit, OnDestroy { formats: Format[] = Formats; qualities: Quality[]; themes: Theme[] = Themes; // Add-form state addUrl = ''; quality: string; format: string; folder = ''; customNamePrefix = ''; autoStart: boolean; playlistStrictMode = false; playlistItemLimit: number | null = null; addInProgress = false; customDirs$: Observable; // Navigation & list state status: DownloadTab = 'queued'; selectedFolder = 'all'; filter = ''; sort: 'date' | 'title' | 'size' = 'date'; sortAscending = false; view: 'list' | 'grid' = 'list'; isAdvancedOpen = false; sidebarOpen = false; activityOpen = false; // In-page video player (completed items) playerUrl: string | null = null; playerTitle = ''; // Library (媒体库) mode mode: 'downloads' | 'library' = 'downloads'; libraryEnabled = false; libraryFolders: LibraryFolder[] = []; libraryFolderNames: string[] = []; libraryFiles: LibraryFile[] = []; libraryFolder = ''; // '' = library root libraryLoading = false; // Organize-to-library modal organizeOpen = false; organizeItems: KeyedDownload[] = []; organizeTarget: string | null = null; organizeInProgress = false; // Metrics activeDownloads = 0; queuedDownloads = 0; completedDownloads = 0; failedDownloads = 0; totalSpeed = 0; librarySize = 0; // Diagnostics / events ytDlpOptionsUpdateTime: string | null = null; ytDlpVersion: string | null = null; metubeVersion: string | null = null; events: Array<{ type: string; message: string; timestamp: number; url: string }> = []; // Batch import modal batchImportModalOpen = false; batchImportText = ''; batchImportStatus = ''; importInProgress = false; cancelImportFlag = false; activeTheme: Theme; constructor( public downloads: DownloadsService, private preferences: PreferencesService, private toastService: ToastService, private http: HttpClient ) { this.format = this.preferences.get('metube_format', 'any'); this.setQualities(); this.quality = this.preferences.get('metube_quality', 'best'); this.autoStart = this.preferences.getBool('metube_auto_start', true); this.sortAscending = this.preferences.getBool('metube_sort_order', false); this.folder = this.preferences.get('metube_folder', ''); const savedStatus = this.preferences.get('metube_status', 'queued'); this.status = savedStatus === 'downloading' || savedStatus === 'completed' ? savedStatus : 'queued'; const savedFolder = this.preferences.get('metube_folder_nav', 'all'); this.selectedFolder = savedFolder; const savedView = this.preferences.get('metube_view', 'list'); this.view = savedView === 'grid' ? 'grid' : 'list'; this.mode = this.preferences.get('metube_mode', 'downloads') === 'library' ? 'library' : 'downloads'; this.activeTheme = this.getPreferredTheme(); this.downloads.queueChanged.subscribe(() => this.updateMetrics()); this.downloads.doneChanged.subscribe(() => this.updateMetrics()); this.downloads.updated.subscribe(() => this.updateMetrics()); this.downloads.eventReceived.subscribe((event: any) => { this.events.push(event); if (this.events.length > 5) this.events = this.events.slice(-5); }); } ngOnInit() { this.getConfiguration(); this.getYtdlOptionsUpdateTime(); this.customDirs$ = this.getMatchingCustomDir(); this.setTheme(this.activeTheme); this.loadEvents(); this.fetchVersionInfo(); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if (this.activeTheme.id === 'auto') this.setTheme(this.activeTheme); }); // Listen in the capture phase so Escape closes the player even when the // native video controls (shadow DOM) hold focus after seeking and would // otherwise swallow the keydown before it bubbles to the document. document.addEventListener('keydown', this.onPlayerKeydown, true); } ngOnDestroy(): void { document.removeEventListener('keydown', this.onPlayerKeydown, true); } // ---------- Navigation ---------- onStatusChange(status: DownloadTab): void { this.status = status; this.preferences.set('metube_status', status); if (this.mode !== 'downloads') this.setMode('downloads'); } onFolderChange(folder: string): void { this.selectedFolder = folder; this.preferences.set('metube_folder_nav', folder); } setSortDirection(ascending: boolean): void { this.sortAscending = ascending; this.preferences.setBool('metube_sort_order', ascending); } setView(view: 'list' | 'grid'): void { this.view = view; this.preferences.set('metube_view', view); } toggleAdvanced(open?: boolean): void { this.isAdvancedOpen = open === undefined ? !this.isAdvancedOpen : open; } // ---------- In-page player ---------- openPlayer(row: KeyedDownload): void { this.playerUrl = this.buildDownloadLink(row.value); this.playerTitle = row.value.title; } closePlayer(): void { this.playerUrl = null; this.playerTitle = ''; } private onPlayerKeydown = (event: KeyboardEvent): void => { if (event.key === 'Escape' && this.playerUrl) { // The native video controls (shadow DOM) would otherwise consume the // key after a seek — stop it so the player closes cleanly instead of // only highlighting the progress bar. event.preventDefault(); event.stopPropagation(); this.closePlayer(); } }; onVideoFocusIn(event: FocusEvent): void { // The native media controls live in a closed UA shadow root; once one of // them (e.g. the progress bar after seeking) holds focus, the browser // consumes Escape internally and no DOM keydown reaches the page. Blur // the video so focus returns to the document and Escape can close it. (event.currentTarget as HTMLVideoElement).blur(); } // ---------- Library (媒体库) ---------- setMode(mode: 'downloads' | 'library'): void { this.mode = mode; this.preferences.set('metube_mode', mode); if (mode === 'library') { this.loadLibraryFolders(); this.loadLibraryFiles(); } } loadLibraryFolders(): void { if (!this.libraryEnabled) return; this.downloads.libraryFolders().subscribe({ next: (res) => { this.libraryFolders = res.folders; this.libraryFolderNames = res.folders.map(f => f.name).filter(n => n); }, error: () => { this.libraryFolders = []; this.libraryFolderNames = []; } }); } selectLibraryFolder(folder: string): void { this.libraryFolder = folder; this.loadLibraryFiles(); } loadLibraryFiles(): void { if (!this.libraryEnabled) return; this.libraryLoading = true; this.downloads.libraryFiles(this.libraryFolder).subscribe({ next: (res) => { this.libraryFiles = res.files; this.libraryLoading = false; }, error: () => { this.libraryFiles = []; this.libraryLoading = false; } }); } get libraryTotal(): number { return this.libraryFolders.reduce((total, f) => total + f.count, 0); } libraryFilePath(file: LibraryFile): string { return this.libraryFolder ? this.libraryFolder + '/' + file.name : file.name; } playLibraryFile(file: LibraryFile): void { this.playerUrl = this.downloads.libraryFileUrl(this.libraryFilePath(file)); this.playerTitle = file.name; } deleteLibraryFile(file: LibraryFile): void { this.deleteLibraryFiles([this.libraryFilePath(file)]); } deleteLibraryFiles(paths: string[]): void { if (!paths.length) return; if (!confirm(`确定删除所选 ${paths.length} 个文件吗?此操作不可恢复。`)) return; this.downloads.libraryDelete(paths).subscribe((res) => { if (res.status === 'error') { this.toastService.show(`删除失败: ${res.msg}`, { error: true }); return; } if (res.errors?.length) { this.toastService.show(res.errors.join('; '), { error: true }); } else { this.toastService.show(paths.length === 1 ? '已删除' : `已删除 ${paths.length} 个文件`); } this.loadLibraryFiles(); this.loadLibraryFolders(); }); } moveLibraryFiles(evt: { paths: string[]; folder: string }): void { if (!evt.paths.length) return; this.downloads.libraryMove(evt.paths, evt.folder).subscribe((res) => { if (res.status === 'error') { this.toastService.show(`移动失败: ${res.msg}`, { error: true }); return; } if (res.errors?.length) { this.toastService.show(`已移动 ${res.moved || 0} 个文件,${res.errors.length} 个失败: ${res.errors[0]}`, { error: true }); } else { this.toastService.show(`已移动 ${res.moved || 0} 个文件到「${evt.folder || '根目录'}」`); } this.loadLibraryFiles(); this.loadLibraryFolders(); }); } // ---------- Organize to library ---------- openOrganize(keys: string[]): void { const items: KeyedDownload[] = []; keys.forEach(key => { const dl = this.downloads.done.get(key); if (dl && dl.status === 'finished') items.push({ key, value: dl }); }); if (!items.length) { this.toastService.show('所选任务没有可整理的文件', { error: true }); return; } this.organizeItems = items; this.organizeTarget = null; this.organizeInProgress = false; this.organizeOpen = true; if (this.libraryFolders.length === 0) this.loadLibraryFolders(); } closeOrganize(): void { this.organizeOpen = false; } private normalizeName(s: string): string { return (s || '').toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, ' ').trim(); } // Best-guess library folders for the items being organized: uploader match // is the strongest signal, then title-token overlap get organizeSuggestions(): string[] { const scores = new Map(); for (const item of this.organizeItems) { const uploader = this.normalizeName(item.value.entry?.uploader || ''); const titleTokens = new Set(this.normalizeName(item.value.title).split(' ').filter(t => t.length > 2)); for (const f of this.libraryFolders) { if (!f.name) continue; const folderNorm = this.normalizeName(f.name); if (!folderNorm) continue; let score = 0; if (uploader && folderNorm === uploader) score += 100; else if (uploader && (folderNorm.includes(uploader) || uploader.includes(folderNorm))) score += 50; const overlap = folderNorm.split(' ').filter(t => t.length > 2 && titleTokens.has(t)).length; score += overlap * 10; if (score > 0) scores.set(f.name, (scores.get(f.name) || 0) + score); } } return Array.from(scores.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 3) .map(([name]) => name); } confirmOrganize(folder: string | null): void { if (folder === null || this.organizeInProgress) return; const ids = this.organizeItems.map(i => i.key); this.organizeInProgress = true; this.downloads.libraryOrganize(ids, folder).subscribe((res) => { this.organizeInProgress = false; if (res.status === 'error') { this.toastService.show(`整理失败: ${res.msg}`, { error: true }); return; } this.organizeOpen = false; if (res.errors?.length) { this.toastService.show(`已整理 ${res.moved || 0} 个文件,${res.errors.length} 个失败: ${res.errors[0]}`, { error: true }); } else { this.toastService.show(`已整理 ${res.moved || 0} 个文件到「${folder || '根目录'}」`); } this.loadLibraryFolders(); if (this.mode === 'library') this.loadLibraryFiles(); }); } // ---------- Data getters ---------- statusEntries(tab: DownloadTab): KeyedDownload[] { if (tab === 'completed') { return Array.from(this.downloads.done.entries()).map(([key, value]) => ({ key, value })); } const entries: KeyedDownload[] = []; this.downloads.queue.forEach((dl, key) => { if (tab === 'queued' ? dl.status === 'pending' : dl.status !== 'pending') { entries.push({ key, value: dl }); } }); return entries; } get downloadingRows(): KeyedDownload[] { return this.statusEntries('downloading').sort((a, b) => (a.value.timestamp || 0) - (b.value.timestamp || 0)); } get filteredRows(): KeyedDownload[] { let rows = this.statusEntries(this.status); if (this.selectedFolder !== 'all') { rows = rows.filter(r => (r.value.folder || '') === this.selectedFolder); } const q = this.filter.trim().toLowerCase(); if (q) { rows = rows.filter(r => r.value.title && r.value.title.toLowerCase().includes(q)); } const dir = this.sortAscending ? 1 : -1; if (this.sort === 'title') { rows.sort((a, b) => a.value.title.localeCompare(b.value.title, 'zh') * dir); } else if (this.sort === 'size') { rows.sort((a, b) => ((a.value.size || 0) - (b.value.size || 0)) * dir); } else { rows.sort((a, b) => ((a.value.timestamp || 0) - (b.value.timestamp || 0)) * dir); } return rows; } get statusCounts(): { downloading: number; queued: number; completed: number } { return { downloading: this.activeDownloads, queued: this.queuedDownloads, completed: this.downloads.done.size }; } get folderNavs(): { name: string; count: number }[] { const counts = new Map(); for (const { value } of this.statusEntries(this.status)) { const key = value.folder || ''; counts.set(key, (counts.get(key) || 0) + 1); } return Array.from(counts.entries()) .map(([name, count]) => ({ name, count })) .sort((a, b) => a.name.localeCompare(b.name, 'zh')); } get allCount(): number { return this.statusEntries(this.status).length; } get folderName(): string { return this.selectedFolder === 'all' ? '全部文件夹' : (this.selectedFolder || '未分类'); } // ---------- Add / download actions ---------- setQuality(quality: string): void { this.quality = quality; this.preferences.set('metube_quality', quality); this.downloads.customDirsChanged.next(this.downloads.customDirs); } setFormat(format: string): void { this.format = format; this.preferences.set('metube_format', format); this.setQualities(); this.downloads.customDirsChanged.next(this.downloads.customDirs); } setFolder(folder: string | null): void { this.folder = folder || ''; this.preferences.set('metube_folder', this.folder); } setAutoStart(autoStart: boolean): void { this.autoStart = autoStart; this.preferences.setBool('metube_auto_start', autoStart); } addDownload(url?: string, quality?: string, format?: string, folder?: string, customNamePrefix?: string, playlistStrictMode?: boolean, playlistItemLimit?: number, autoStart?: boolean) { url = url ?? this.addUrl; quality = quality ?? this.quality; format = format ?? this.format; folder = folder ?? this.folder; customNamePrefix = customNamePrefix ?? this.customNamePrefix; playlistStrictMode = playlistStrictMode ?? this.playlistStrictMode; playlistItemLimit = playlistItemLimit ?? this.playlistItemLimit; autoStart = autoStart ?? this.autoStart; if (!url || !/^https?:\/\//i.test(url.trim())) { this.toastService.show('请先粘贴一个有效的视频或播放列表 URL', { error: true }); return; } this.addInProgress = true; this.downloads.add(url.trim(), quality, format, folder, customNamePrefix, playlistStrictMode, playlistItemLimit, autoStart).subscribe((status: Status) => { this.addInProgress = false; if (status.status === 'error') { this.toastService.show(`添加失败: ${status.msg}`, { error: true }); } else if (status.msg) { // Backend skipped the URL (e.g. "Download already exists") — surface // that instead of a misleading "added to queue". this.toastService.show(status.msg, { error: true }); this.loadEvents(); } else { this.addUrl = ''; this.toastService.show('已加入队列'); this.loadEvents(); } }); } startItems(ids: string[]): void { if (!ids.length) return; this.downloads.startById(ids).subscribe(() => { this.toastService.show(`已开始 ${ids.length} 个任务`); }); } delItems(ids: string[], where: 'queue' | 'done' = 'done'): void { if (!ids.length) return; this.downloads.delById(where, ids).subscribe(() => { this.toastService.show(`已删除 ${ids.length} 项`); }); } cancelDownload(key: string): void { this.downloads.delById('queue', [key]).subscribe(() => { this.toastService.show('已取消下载'); }); } moveItems(ids: string[], folder: string): void { if (!ids.length) return; this.downloads.moveById(ids, folder).subscribe((status: Status) => { if (status.status === 'error') { this.toastService.show(`移动失败: ${status.msg}`, { error: true }); } else { this.toastService.show(`已将 ${ids.length} 项移动到 ${folder || '默认目录'}`); } }); } folderChanged(key: string, folder: string): void { this.downloads.moveById([key], folder).subscribe((status: Status) => { if (status.status === 'error') { this.toastService.show(`更改目录失败: ${status.msg}`, { error: true }); } }); } retryDownload(key: string, download: Download): void { this.addDownload(download.url, download.quality, download.format, download.folder, download.custom_name_prefix, download.playlist_strict_mode, download.playlist_item_limit, true); this.downloads.delById('done', [key]).subscribe(); } clearCompletedDownloads(): void { this.downloads.delByFilter('done', dl => dl.status === 'finished').subscribe(); } clearFailedDownloads(): void { this.downloads.delByFilter('done', dl => dl.status === 'error').subscribe(); } retryFailedDownloads(): void { this.downloads.done.forEach((dl, key) => { if (dl.status === 'error') this.retryDownload(key, dl); }); } downloadSelectedFiles(ids: string[]): void { let count = 0; ids.forEach(key => { const dl = this.downloads.done.get(key); if (dl && dl.status === 'finished' && dl.filename) { const link = document.createElement('a'); link.href = this.buildDownloadLink(dl); link.setAttribute('download', dl.filename); link.setAttribute('target', '_self'); document.body.appendChild(link); link.click(); document.body.removeChild(link); count++; } }); if (count === 0) { this.toastService.show('所选任务没有可下载的文件', { error: true }); } } buildDownloadLink(download: Download): string { let baseDir = this.downloads.configuration['PUBLIC_HOST_URL']; if (download.quality === 'audio' || (download.filename && download.filename.endsWith('.mp3'))) { baseDir = this.downloads.configuration['PUBLIC_HOST_AUDIO_URL']; } if (download.folder) { baseDir += download.folder + '/'; } return baseDir + encodeURIComponent(download.filename); } // ---------- Batch import / export / cookies ---------- openBatchImportModal(): void { this.batchImportModalOpen = true; this.batchImportText = ''; this.batchImportStatus = ''; this.importInProgress = false; this.cancelImportFlag = false; } closeBatchImportModal(): void { this.batchImportModalOpen = false; } startBatchImport(): void { const urls = this.batchImportText.split(/\r?\n/).map(u => u.trim()).filter(u => u.length > 0); if (urls.length === 0) { this.toastService.show('没有找到有效的 URL', { error: true }); return; } this.importInProgress = true; this.cancelImportFlag = false; this.batchImportStatus = `开始导入 ${urls.length} 个 URL…`; let index = 0; const delayBetween = 1000; const processNext = () => { if (this.cancelImportFlag) { this.batchImportStatus = `已取消导入(完成 ${index}/${urls.length})`; this.importInProgress = false; return; } if (index >= urls.length) { this.batchImportStatus = `已完成 ${urls.length} 个 URL 的导入`; this.importInProgress = false; this.toastService.show(`导入完成,共 ${urls.length} 条`); return; } const url = urls[index]; this.batchImportStatus = `正在导入 ${index + 1}/${urls.length}: ${url}`; this.downloads.add(url, this.quality, this.format, this.folder, this.customNamePrefix, this.playlistStrictMode, this.playlistItemLimit, this.autoStart) .subscribe({ next: (status: Status) => { if (status.status === 'error') { this.toastService.show(`导入失败 ${url}: ${status.msg}`, { error: true }); } index++; setTimeout(processNext, delayBetween); }, error: (err) => { console.error(`Error importing URL ${url}:`, err); index++; setTimeout(processNext, delayBetween); } }); }; processNext(); } cancelBatchImport(): void { if (this.importInProgress) { this.cancelImportFlag = true; this.batchImportStatus += ' 正在取消…'; } } private collectUrls(filter: 'pending' | 'completed' | 'failed' | 'all'): string[] { if (filter === 'pending') { return Array.from(this.downloads.queue.values()).map(dl => dl.url); } if (filter === 'completed') { return Array.from(this.downloads.done.values()).filter(dl => dl.status === 'finished').map(dl => dl.url); } if (filter === 'failed') { return Array.from(this.downloads.done.values()).filter(dl => dl.status === 'error').map(dl => dl.url); } return [ ...Array.from(this.downloads.queue.values()).map(dl => dl.url), ...Array.from(this.downloads.done.values()).map(dl => dl.url) ]; } exportBatchUrls(filter: 'pending' | 'completed' | 'failed' | 'all'): void { const urls = this.collectUrls(filter); if (!urls.length) { this.toastService.show('没有可导出的 URL', { error: true }); return; } const blob = new Blob([urls.join('\n')], { type: 'text/plain' }); const downloadUrl = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = downloadUrl; a.download = 'metube_urls.txt'; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(downloadUrl); this.toastService.show(`已导出 ${urls.length} 条 URL`); } copyBatchUrls(filter: 'pending' | 'completed' | 'failed' | 'all'): void { const urls = this.collectUrls(filter); if (!urls.length) { this.toastService.show('没有可复制的 URL', { error: true }); return; } navigator.clipboard.writeText(urls.join('\n')) .then(() => this.toastService.show(`已复制 ${urls.length} 条 URL`)) .catch(() => this.toastService.show('复制失败', { error: true })); } saveCookie(domainOrUrl: string, cookie: string): void { const isUrl = /^https?:\/\//i.test(domainOrUrl); const url = isUrl ? domainOrUrl : ''; const domain = isUrl ? '' : domainOrUrl.replace(/^www\./i, ''); this.downloads.setCookie(cookie, domain, url).subscribe((status: any) => { if (status.status === 'error') { this.toastService.show(`Cookie 保存失败: ${status.msg}`, { error: true }); } else { this.toastService.show(`Cookie 已保存(${status.cookie_count || ''} 条)`); } }); } // ---------- Configuration / theme / diagnostics ---------- allowCustomDir(tag: string) { return this.downloads.configuration['CREATE_CUSTOM_DIRS'] ? tag : false; } isAudioType(): boolean { return this.quality === 'audio' || ['mp3', 'm4a', 'opus', 'wav', 'flac'].includes(this.format); } getMatchingCustomDir(): Observable { return this.downloads.customDirsChanged.asObservable().pipe( map((output: any) => { if (this.isAudioType()) { return output['audio_download_dir']; } return output['download_dir']; }), distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr)) ); } customDirsForDownload(dl: Download): string[] { const isAudio = dl.quality === 'audio' || ['m4a', 'mp3', 'opus', 'wav', 'flac'].includes(dl.format); const dirs = this.downloads.customDirs[isAudio ? 'audio_download_dir' : 'download_dir']; return dirs || []; } setQualities(): void { const format = this.formats.find(el => el.id === this.format) || this.formats[0]; this.qualities = format.qualities; const exists = this.qualities.find(el => el.id === this.quality); this.quality = exists ? this.quality : 'best'; } getPreferredTheme(): Theme { const id = this.preferences.get('metube_theme', 'auto'); return this.themes.find(x => x.id === id) ?? this.themes.find(x => x.id === 'auto'); } themeChanged(theme: Theme): void { this.preferences.set('metube_theme', theme.id); this.setTheme(theme); } setTheme(theme: Theme): void { this.activeTheme = theme; if (theme.id === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) { document.documentElement.setAttribute('data-bs-theme', 'dark'); } else { document.documentElement.setAttribute('data-bs-theme', theme.id); } } getConfiguration(): void { this.downloads.configurationChanged.subscribe({ next: (config: any) => { this.playlistStrictMode = config['DEFAULT_OPTION_PLAYLIST_STRICT_MODE']; const limit = config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT']; if (limit !== '0') this.playlistItemLimit = limit; const wasEnabled = this.libraryEnabled; this.libraryEnabled = !!config['LIBRARY_DIR']; if (!this.libraryEnabled && this.mode === 'library') this.mode = 'downloads'; if (this.libraryEnabled && !wasEnabled && this.libraryFolders.length === 0) this.loadLibraryFolders(); } }); } getYtdlOptionsUpdateTime(): void { this.downloads.ytdlOptionsChanged.subscribe({ next: (data: any) => { if (data['success']) { this.ytDlpOptionsUpdateTime = new Date(data['update_time'] * 1000).toLocaleString(); } else { this.toastService.show(`加载 yt-dlp 选项失败: ${data['msg']}`, { error: true }); } } }); } fetchVersionInfo(): void { const baseUrl = `${window.location.origin}${window.location.pathname.replace(/\/[^\/]*$/, '/')}`; this.http.get<{ 'yt-dlp': string; version: string }>(`${baseUrl}version`) .subscribe({ next: (data) => { this.ytDlpVersion = data['yt-dlp']; this.metubeVersion = data.version; }, error: () => { this.ytDlpVersion = null; this.metubeVersion = null; } }); } loadEvents(): void { this.http.get('/events').subscribe((events: any[]) => { this.events = events || []; }); } clearEvents(): void { this.http.post('/events/clear', {}).subscribe(() => { this.events = []; }); } getRelativeTime(timestamp: number): string { const now = Date.now() / 1000; const diff = Math.floor(now - timestamp); if (diff < 60) return '刚刚'; if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`; if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`; return `${Math.floor(diff / 86400)} 天前`; } private updateMetrics(): void { let active = 0; let queued = 0; let speed = 0; this.downloads.queue.forEach(dl => { if (dl.status === 'downloading' || dl.status === 'preparing') { active++; speed += dl.speed || 0; } else if (dl.status === 'pending') { queued++; } }); this.activeDownloads = active; this.queuedDownloads = queued; this.totalSpeed = speed; let completed = 0; let failed = 0; let library = 0; this.downloads.done.forEach(dl => { if (dl.status === 'finished') { completed++; library += dl.size || 0; } else if (dl.status === 'error') { failed++; } }); this.completedDownloads = completed; this.failedDownloads = failed; this.librarySize = library; } }