845 lines
29 KiB
TypeScript
845 lines
29 KiB
TypeScript
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<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);
|
|
});
|
|
|
|
// 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<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 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<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;
|
|
}
|
|
}
|