metube/ui/src/app/app.component.ts

614 lines
20 KiB
TypeScript

import { Component, 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<string[]>;
// 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;
// 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;
}
// ---------- 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<string, number>();
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<string[]> {
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;
}
}