import { AsyncPipe, KeyValuePipe } from '@angular/common'; import { HttpClient } from '@angular/common/http'; import { AfterViewInit, Component, ElementRef, viewChild, inject, OnInit } from '@angular/core'; import { Observable, map, distinctUntilChanged } from 'rxjs'; import { FormsModule } from '@angular/forms'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgSelectModule } from '@ng-select/ng-select'; import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt } from '@fortawesome/free-solid-svg-icons'; import { faGithub } from '@fortawesome/free-brands-svg-icons'; import { CookieService } from 'ngx-cookie-service'; import { DownloadsService } from './services/downloads.service'; import { Themes } from './theme'; import { Download, Status, Theme , Quality, Format, Formats, State } from './interfaces'; import { EtaPipe, SpeedPipe, FileSizePipe } from './pipes'; import { MasterCheckboxComponent , SlaveCheckboxComponent} from './components/'; @Component({ selector: 'app-root', imports: [ FormsModule, KeyValuePipe, AsyncPipe, FontAwesomeModule, NgbModule, NgSelectModule, EtaPipe, SpeedPipe, FileSizePipe, MasterCheckboxComponent, SlaveCheckboxComponent, ], templateUrl: './app.html', styleUrl: './app.sass', }) export class App implements AfterViewInit, OnInit { downloads = inject(DownloadsService); private cookieService = inject(CookieService); private http = inject(HttpClient); addUrl!: string; formats: Format[] = Formats; qualities!: Quality[]; quality: string; format: string; folder!: string; customNamePrefix!: string; autoStart: boolean; playlistStrictMode!: boolean; playlistItemLimit!: number; addInProgress = false; themes: Theme[] = Themes; activeTheme: Theme | undefined; customDirs$!: Observable; showBatchPanel = false; batchImportModalOpen = false; batchImportText = ''; batchImportStatus = ''; importInProgress = false; cancelImportFlag = false; ytDlpOptionsUpdateTime: string | null = null; ytDlpVersion: string | null = null; metubeVersion: string | null = null; isAdvancedOpen = false; // Download metrics activeDownloads = 0; queuedDownloads = 0; completedDownloads = 0; failedDownloads = 0; totalSpeed = 0; readonly queueMasterCheckbox = viewChild('queueMasterCheckboxRef'); readonly queueDelSelected = viewChild.required('queueDelSelected'); readonly queueDownloadSelected = viewChild.required('queueDownloadSelected'); readonly doneMasterCheckbox = viewChild('doneMasterCheckboxRef'); readonly doneDelSelected = viewChild.required('doneDelSelected'); readonly doneClearCompleted = viewChild.required('doneClearCompleted'); readonly doneClearFailed = viewChild.required('doneClearFailed'); readonly doneRetryFailed = viewChild.required('doneRetryFailed'); readonly doneDownloadSelected = viewChild.required('doneDownloadSelected'); faTrashAlt = faTrashAlt; faCheckCircle = faCheckCircle; faTimesCircle = faTimesCircle; faRedoAlt = faRedoAlt; faSun = faSun; faMoon = faMoon; faCheck = faCheck; faCircleHalfStroke = faCircleHalfStroke; faDownload = faDownload; faExternalLinkAlt = faExternalLinkAlt; faFileImport = faFileImport; faFileExport = faFileExport; faCopy = faCopy; faGithub = faGithub; faClock = faClock; faTachometerAlt = faTachometerAlt; constructor() { this.format = this.cookieService.get('metube_format') || 'any'; // Needs to be set or qualities won't automatically be set this.setQualities() this.quality = this.cookieService.get('metube_quality') || 'best'; this.autoStart = this.cookieService.get('metube_auto_start') !== 'false'; this.activeTheme = this.getPreferredTheme(this.cookieService); // Subscribe to download updates this.downloads.queueChanged.subscribe(() => { this.updateMetrics(); }); this.downloads.doneChanged.subscribe(() => { this.updateMetrics(); }); // Subscribe to real-time updates this.downloads.updated.subscribe(() => { this.updateMetrics(); }); } ngOnInit() { this.getConfiguration(); this.getYtdlOptionsUpdateTime(); this.customDirs$ = this.getMatchingCustomDir(); this.setTheme(this.activeTheme!); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if (this.activeTheme && this.activeTheme.id === 'auto') { this.setTheme(this.activeTheme); } }); } ngAfterViewInit() { this.downloads.queueChanged.subscribe(() => { this.queueMasterCheckbox()?.selectionChanged(); }); this.downloads.doneChanged.subscribe(() => { this.doneMasterCheckbox()?.selectionChanged(); let completed = 0, failed = 0; this.downloads.done.forEach(dl => { if (dl.status === 'finished') completed++; else if (dl.status === 'error') failed++; }); this.doneClearCompleted().nativeElement.disabled = completed === 0; this.doneClearFailed().nativeElement.disabled = failed === 0; this.doneRetryFailed().nativeElement.disabled = failed === 0; }); this.fetchVersionInfo(); } // workaround to allow fetching of Map values in the order they were inserted // https://github.com/angular/angular/issues/31420 asIsOrder() { return 1; } qualityChanged() { this.cookieService.set('metube_quality', this.quality, { expires: 3650 }); // Re-trigger custom directory change this.downloads.customDirsChanged.next(this.downloads.customDirs); } showAdvanced() { return this.downloads.configuration['CUSTOM_DIRS']; } allowCustomDir(tag: string) { if (this.downloads.configuration['CREATE_CUSTOM_DIRS']) { return tag; } return false; } isAudioType() { return this.quality == 'audio' || this.format == 'mp3' || this.format == 'm4a' || this.format == 'opus' || this.format == 'wav' || this.format == 'flac'; } getMatchingCustomDir() : Observable { return this.downloads.customDirsChanged.asObservable().pipe( // eslint-disable-next-line @typescript-eslint/no-explicit-any map((output: any) => { // Keep logic consistent with app/ytdl.py if (this.isAudioType()) { console.debug("Showing audio-specific download directories"); return output["audio_download_dir"]; } else { console.debug("Showing default download directories"); return output["download_dir"]; } }), distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr)) ); } getYtdlOptionsUpdateTime() { this.downloads.ytdlOptionsChanged.subscribe({ // eslint-disable-next-line @typescript-eslint/no-explicit-any next: (data:any) => { if (data['success']){ const date = new Date(data['update_time'] * 1000); this.ytDlpOptionsUpdateTime=date.toLocaleString(); }else{ alert("Error reload yt-dlp options: "+data['msg']); } } }); } getConfiguration() { this.downloads.configurationChanged.subscribe({ // eslint-disable-next-line @typescript-eslint/no-explicit-any next: (config: any) => { this.playlistStrictMode = config['DEFAULT_OPTION_PLAYLIST_STRICT_MODE']; const playlistItemLimit = config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT']; if (playlistItemLimit !== '0') { this.playlistItemLimit = playlistItemLimit; } } }); } getPreferredTheme(cookieService: CookieService) { let theme = 'auto'; if (cookieService.check('metube_theme')) { theme = cookieService.get('metube_theme'); } return this.themes.find(x => x.id === theme) ?? this.themes.find(x => x.id === 'auto'); } themeChanged(theme: Theme) { this.cookieService.set('metube_theme', theme.id, { expires: 3650 }); this.setTheme(theme); } setTheme(theme: Theme) { 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); } } formatChanged() { this.cookieService.set('metube_format', this.format, { expires: 3650 }); // Updates to use qualities available this.setQualities() // Re-trigger custom directory change this.downloads.customDirsChanged.next(this.downloads.customDirs); } autoStartChanged() { this.cookieService.set('metube_auto_start', this.autoStart ? 'true' : 'false', { expires: 3650 }); } queueSelectionChanged(checked: number) { this.queueDelSelected().nativeElement.disabled = checked == 0; this.queueDownloadSelected().nativeElement.disabled = checked == 0; } doneSelectionChanged(checked: number) { this.doneDelSelected().nativeElement.disabled = checked == 0; this.doneDownloadSelected().nativeElement.disabled = checked == 0; } setQualities() { // qualities for specific format const format = this.formats.find(el => el.id == this.format) if (format) { this.qualities = format.qualities const exists = this.qualities.find(el => el.id === this.quality) this.quality = exists ? this.quality : 'best' } } 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 console.debug('Downloading: url='+url+' quality='+quality+' format='+format+' folder='+folder+' customNamePrefix='+customNamePrefix+' playlistStrictMode='+playlistStrictMode+' playlistItemLimit='+playlistItemLimit+' autoStart='+autoStart); this.addInProgress = true; this.downloads.add(url, quality, format, folder, customNamePrefix, playlistStrictMode, playlistItemLimit, autoStart).subscribe((status: Status) => { if (status.status === 'error') { alert(`Error adding URL: ${status.msg}`); } else { this.addUrl = ''; } this.addInProgress = false; }); } downloadItemByKey(id: string) { this.downloads.startById([id]).subscribe(); } retryDownload(key: string, download: Download) { 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(); } delDownload(where: State, id: string) { this.downloads.delById(where, [id]).subscribe(); } startSelectedDownloads(where: State){ this.downloads.startByFilter(where, dl => !!dl.checked).subscribe(); } delSelectedDownloads(where: State) { this.downloads.delByFilter(where, dl => !!dl.checked).subscribe(); } clearCompletedDownloads() { this.downloads.delByFilter('done', dl => dl.status === 'finished').subscribe(); } clearFailedDownloads() { this.downloads.delByFilter('done', dl => dl.status === 'error').subscribe(); } retryFailedDownloads() { this.downloads.done.forEach((dl, key) => { if (dl.status === 'error') { this.retryDownload(key, dl); } }); } downloadSelectedFiles() { // eslint-disable-next-line @typescript-eslint/no-unused-vars this.downloads.done.forEach((dl, _) => { if (dl.status === 'finished' && dl.checked) { 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); } }); } buildDownloadLink(download: Download) { let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"]; if (download.quality == 'audio' || download.filename.endsWith('.mp3')) { baseDir = this.downloads.configuration["PUBLIC_HOST_AUDIO_URL"]; } if (download.folder) { baseDir += download.folder + '/'; } return baseDir + encodeURIComponent(download.filename); } isNumber(event: KeyboardEvent) { const charCode = +event.code || event.keyCode; if (charCode > 31 && (charCode < 48 || charCode > 57)) { event.preventDefault(); } } // Toggle inline batch panel (if you want to use an inline panel for export; not used for import modal) toggleBatchPanel(): void { this.showBatchPanel = !this.showBatchPanel; } // Open the Batch Import modal openBatchImportModal(): void { this.batchImportModalOpen = true; this.batchImportText = ''; this.batchImportStatus = ''; this.importInProgress = false; this.cancelImportFlag = false; } // Close the Batch Import modal closeBatchImportModal(): void { this.batchImportModalOpen = false; } // Start importing URLs from the batch modal textarea startBatchImport(): void { const urls = this.batchImportText .split(/\r?\n/) .map(url => url.trim()) .filter(url => url.length > 0); if (urls.length === 0) { alert('No valid URLs found.'); return; } this.importInProgress = true; this.cancelImportFlag = false; this.batchImportStatus = `Starting to import ${urls.length} URLs...`; let index = 0; const delayBetween = 1000; const processNext = () => { if (this.cancelImportFlag) { this.batchImportStatus = `Import cancelled after ${index} of ${urls.length} URLs.`; this.importInProgress = false; return; } if (index >= urls.length) { this.batchImportStatus = `Finished importing ${urls.length} URLs.`; this.importInProgress = false; return; } const url = urls[index]; this.batchImportStatus = `Importing URL ${index + 1} of ${urls.length}: ${url}`; // Now pass the selected quality, format, folder, etc. to the add() method 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') { alert(`Error adding URL ${url}: ${status.msg}`); } index++; setTimeout(processNext, delayBetween); }, error: (err) => { console.error(`Error importing URL ${url}:`, err); index++; setTimeout(processNext, delayBetween); } }); }; processNext(); } // Cancel the batch import process cancelBatchImport(): void { if (this.importInProgress) { this.cancelImportFlag = true; this.batchImportStatus += ' Cancelling...'; } } // Export URLs based on filter: 'pending', 'completed', 'failed', or 'all' exportBatchUrls(filter: 'pending' | 'completed' | 'failed' | 'all'): void { let urls: string[]; if (filter === 'pending') { urls = Array.from(this.downloads.queue.values()).map(dl => dl.url); } else if (filter === 'completed') { // Only finished downloads in the "done" Map urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'finished').map(dl => dl.url); } else if (filter === 'failed') { // Only error downloads from the "done" Map urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'error').map(dl => dl.url); } else { // All: pending + both finished and error in done urls = [ ...Array.from(this.downloads.queue.values()).map(dl => dl.url), ...Array.from(this.downloads.done.values()).map(dl => dl.url) ]; } if (!urls.length) { alert('No URLs found for the selected filter.'); return; } const content = urls.join('\n'); const blob = new Blob([content], { 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); } // Copy URLs to clipboard based on filter: 'pending', 'completed', 'failed', or 'all' copyBatchUrls(filter: 'pending' | 'completed' | 'failed' | 'all'): void { let urls: string[]; if (filter === 'pending') { urls = Array.from(this.downloads.queue.values()).map(dl => dl.url); } else if (filter === 'completed') { urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'finished').map(dl => dl.url); } else if (filter === 'failed') { urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'error').map(dl => dl.url); } else { urls = [ ...Array.from(this.downloads.queue.values()).map(dl => dl.url), ...Array.from(this.downloads.done.values()).map(dl => dl.url) ]; } if (!urls.length) { alert('No URLs found for the selected filter.'); return; } const content = urls.join('\n'); navigator.clipboard.writeText(content) .then(() => alert('URLs copied to clipboard.')) .catch(() => alert('Failed to copy URLs.')); } fetchVersionInfo(): void { // eslint-disable-next-line no-useless-escape const baseUrl = `${window.location.origin}${window.location.pathname.replace(/\/[^\/]*$/, '/')}`; const versionUrl = `${baseUrl}version`; this.http.get<{ 'yt-dlp': string, version: string }>(versionUrl) .subscribe({ next: (data) => { this.ytDlpVersion = data['yt-dlp']; this.metubeVersion = data.version; }, error: () => { this.ytDlpVersion = null; this.metubeVersion = null; } }); } toggleAdvanced() { this.isAdvancedOpen = !this.isAdvancedOpen; } private updateMetrics() { this.activeDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'downloading' || d.status === 'preparing').length; this.queuedDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'pending').length; this.completedDownloads = Array.from(this.downloads.done.values()).filter(d => d.status === 'finished').length; this.failedDownloads = Array.from(this.downloads.done.values()).filter(d => d.status === 'error').length; // Calculate total speed from downloading items const downloadingItems = Array.from(this.downloads.queue.values()) .filter(d => d.status === 'downloading'); this.totalSpeed = downloadingItems.reduce((total, item) => total + (item.speed || 0), 0); } }