import { Component, HostListener, OnInit } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, distinctUntilChanged, map } from 'rxjs'; import { Download, DownloadsService, 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 { 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 = ''; // 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.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); }); } // ---------- Navigation ---------- onStatusChange(status: DownloadTab): void { this.status = status; this.preferences.set('metube_status', status); } 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 = ''; } @HostListener('document:keydown.escape') onEscape(): void { if (this.playerUrl) this.closePlayer(); } // ---------- 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 { 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; } }); } 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; } }