From 58c317f7cdf4bee26151bd21e516d78689ec7fbd Mon Sep 17 00:00:00 2001 From: Alex Shnitman Date: Mon, 2 Mar 2026 20:21:04 +0200 Subject: [PATCH] enhance playlist addition cancellation and improve error handling UI --- app/ytdl.py | 13 +++-- ui/src/app/app.html | 34 ++++++++----- ui/src/app/app.ts | 61 ++++++++++++++++++------ ui/src/app/services/downloads.service.ts | 2 +- 4 files changed, 78 insertions(+), 32 deletions(-) diff --git a/app/ytdl.py b/app/ytdl.py index b8e4b0c..12b9d84 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -535,10 +535,10 @@ class DownloadQueue: self.active_downloads = set() self.semaphore = asyncio.Semaphore(int(self.config.MAX_CONCURRENT_DOWNLOADS)) self.done.load() - self._add_canceled = False + self._add_generation = 0 def cancel_add(self): - self._add_canceled = True + self._add_generation += 1 log.info('Playlist add operation canceled by user') async def __import_queue(self): @@ -660,6 +660,7 @@ class DownloadQueue: subtitle_language, subtitle_mode, already, + _add_gen=None, ): if not entry: return {'status': 'error', 'msg': "Invalid/empty data was given."} @@ -690,6 +691,7 @@ class DownloadQueue: subtitle_language, subtitle_mode, already, + _add_gen, ) elif etype == 'playlist' or etype == 'channel': log.debug(f'Processing as a {etype}') @@ -704,7 +706,7 @@ class DownloadQueue: log.info(f'Item limit is set. Processing only first {playlist_item_limit} entries') entries = entries[:playlist_item_limit] for index, etr in enumerate(entries, start=1): - if self._add_canceled: + if _add_gen is not None and self._add_generation != _add_gen: log.info(f'Playlist add canceled after processing {len(already)} entries') return {'status': 'ok', 'msg': f'Canceled - added {len(already)} items before cancel'} etr["_type"] = "video" @@ -728,6 +730,7 @@ class DownloadQueue: subtitle_language, subtitle_mode, already, + _add_gen, ) ) if any(res['status'] == 'error' for res in results): @@ -773,6 +776,7 @@ class DownloadQueue: subtitle_language="en", subtitle_mode="prefer_manual", already=None, + _add_gen=None, ): log.info( f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} ' @@ -780,7 +784,7 @@ class DownloadQueue: f'{subtitle_format=} {subtitle_language=} {subtitle_mode=}' ) if already is None: - self._add_canceled = False + _add_gen = self._add_generation already = set() if already is None else already if url in already: log.info('recursion detected, skipping') @@ -805,6 +809,7 @@ class DownloadQueue: subtitle_language, subtitle_mode, already, + _add_gen, ) async def start_pending(self, ids): diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 00bcdb9..42adcc9 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -98,7 +98,12 @@ name="addUrl" [(ngModel)]="addUrl" [disabled]="addInProgress || downloads.loading"> - @if (addInProgress) { + @if (addInProgress && cancelRequested) { + + } @else if (addInProgress) { @@ -426,15 +431,9 @@ -
- Completed - -
+
Completed
+ @@ -454,7 +453,7 @@ - @for (entry of sortedDone(); track entry[1].id) { + @for (entry of cachedSortedDone; track entry[1].id) { @@ -465,7 +464,12 @@ } @if (entry[1].status === 'error') { - + }
@if (!!entry[1].filename) { @@ -494,9 +498,13 @@
URL: {{entry[1].url}}
diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index fa33ce3..26cca3a 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -53,6 +53,7 @@ export class App implements AfterViewInit, OnInit { subtitleLanguage: string; subtitleMode: string; addInProgress = false; + cancelRequested = false; themes: Theme[] = Themes; activeTheme: Theme | undefined; customDirs$!: Observable; @@ -68,6 +69,8 @@ export class App implements AfterViewInit, OnInit { isAdvancedOpen = false; sortAscending = false; expandedErrors: Set = new Set(); + cachedSortedDone: [string, Download][] = []; + lastCopiedErrorId: string | null = null; // Download metrics activeDownloads = 0; @@ -193,6 +196,7 @@ export class App implements AfterViewInit, OnInit { }); this.downloads.doneChanged.subscribe(() => { this.updateMetrics(); + this.rebuildSortedDone(); }); // Subscribe to real-time updates this.downloads.updated.subscribe(() => { @@ -423,20 +427,24 @@ export class App implements AfterViewInit, OnInit { console.debug('Downloading: url=' + url + ' quality=' + quality + ' format=' + format + ' folder=' + folder + ' customNamePrefix=' + customNamePrefix + ' playlistItemLimit=' + playlistItemLimit + ' autoStart=' + autoStart + ' splitByChapters=' + splitByChapters + ' chapterTemplate=' + chapterTemplate + ' subtitleFormat=' + subtitleFormat + ' subtitleLanguage=' + subtitleLanguage + ' subtitleMode=' + subtitleMode); this.addInProgress = true; + this.cancelRequested = false; this.downloads.add(url, quality, format, folder, customNamePrefix, playlistItemLimit, autoStart, splitByChapters, chapterTemplate, subtitleFormat, subtitleLanguage, subtitleMode).subscribe((status: Status) => { - if (status.status === 'error') { + if (status.status === 'error' && !this.cancelRequested) { alert(`Error adding URL: ${status.msg}`); - } else { + } else if (status.status !== 'error') { this.addUrl = ''; } this.addInProgress = false; + this.cancelRequested = false; }); } cancelAdding() { + this.cancelRequested = true; this.downloads.cancelAdd().subscribe({ - next: () => { this.addInProgress = false; }, - error: () => { this.addInProgress = false; } + error: (err) => { + console.error('Failed to cancel adding:', err?.message || err); + } }); } @@ -715,20 +723,18 @@ export class App implements AfterViewInit, OnInit { toggleSortOrder() { this.sortAscending = !this.sortAscending; this.cookieService.set('metube_sort_ascending', this.sortAscending ? 'true' : 'false', { expires: 3650 }); + this.rebuildSortedDone(); } - sortedDone(): [string, Download][] { + private rebuildSortedDone() { const result: [string, Download][] = []; this.downloads.done.forEach((dl, key) => { result.push([key, dl]); }); - result.sort((a, b) => { - const tsA = (a[1] as any).timestamp || 0; - const tsB = (b[1] as any).timestamp || 0; - const cmp = tsA < tsB ? -1 : tsA > tsB ? 1 : 0; - return this.sortAscending ? cmp : -cmp; - }); - return result; + if (!this.sortAscending) { + result.reverse(); + } + this.cachedSortedDone = result; } toggleErrorDetail(id: string) { @@ -736,13 +742,40 @@ export class App implements AfterViewInit, OnInit { else this.expandedErrors.add(id); } - copyErrorMessage(download: Download) { + copyErrorMessage(id: string, download: Download) { const parts: string[] = []; if (download.title) parts.push(`Title: ${download.title}`); if (download.url) parts.push(`URL: ${download.url}`); if (download.msg) parts.push(`Message: ${download.msg}`); if (download.error) parts.push(`Error: ${download.error}`); - navigator.clipboard.writeText(parts.join('\n')).catch(() => {}); + const text = parts.join('\n'); + if (!text.trim()) return; + const done = () => { + this.lastCopiedErrorId = id; + setTimeout(() => { this.lastCopiedErrorId = null; }, 1500); + }; + const fail = (err?: unknown) => { + console.error('Clipboard write failed:', err); + alert('Failed to copy to clipboard. Your browser may require HTTPS for clipboard access.'); + }; + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(text).then(done).catch(fail); + } else { + try { + const ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + done(); + } catch (e) { + fail(e); + } + } } isErrorExpanded(id: string): boolean { diff --git a/ui/src/app/services/downloads.service.ts b/ui/src/app/services/downloads.service.ts index 4d10edf..e94ca9b 100644 --- a/ui/src/app/services/downloads.service.ts +++ b/ui/src/app/services/downloads.service.ts @@ -209,7 +209,7 @@ export class DownloadsService { return Array.from(this.queue.values()).map(download => download.url); } public cancelAdd() { - return this.http.post('cancel-add', {}).pipe( + return this.http.post('cancel-add', {}).pipe( catchError(this.handleHTTPError) ); }