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

817 lines
27 KiB
TypeScript

import { Component, HostListener, 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 {
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;
// 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);
});
}
// ---------- 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 = '';
}
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.playerUrl) this.closePlayer();
}
// ---------- 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<string, number>();
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<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;
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;
}
}