enhance playlist addition cancellation and improve error handling UI

This commit is contained in:
Alex Shnitman
2026-03-02 20:21:04 +02:00
parent 880eda8435
commit 58c317f7cd
4 changed files with 78 additions and 32 deletions

View File

@@ -535,10 +535,10 @@ class DownloadQueue:
self.active_downloads = set() self.active_downloads = set()
self.semaphore = asyncio.Semaphore(int(self.config.MAX_CONCURRENT_DOWNLOADS)) self.semaphore = asyncio.Semaphore(int(self.config.MAX_CONCURRENT_DOWNLOADS))
self.done.load() self.done.load()
self._add_canceled = False self._add_generation = 0
def cancel_add(self): def cancel_add(self):
self._add_canceled = True self._add_generation += 1
log.info('Playlist add operation canceled by user') log.info('Playlist add operation canceled by user')
async def __import_queue(self): async def __import_queue(self):
@@ -660,6 +660,7 @@ class DownloadQueue:
subtitle_language, subtitle_language,
subtitle_mode, subtitle_mode,
already, already,
_add_gen=None,
): ):
if not entry: if not entry:
return {'status': 'error', 'msg': "Invalid/empty data was given."} return {'status': 'error', 'msg': "Invalid/empty data was given."}
@@ -690,6 +691,7 @@ class DownloadQueue:
subtitle_language, subtitle_language,
subtitle_mode, subtitle_mode,
already, already,
_add_gen,
) )
elif etype == 'playlist' or etype == 'channel': elif etype == 'playlist' or etype == 'channel':
log.debug(f'Processing as a {etype}') 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') log.info(f'Item limit is set. Processing only first {playlist_item_limit} entries')
entries = entries[:playlist_item_limit] entries = entries[:playlist_item_limit]
for index, etr in enumerate(entries, start=1): 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') log.info(f'Playlist add canceled after processing {len(already)} entries')
return {'status': 'ok', 'msg': f'Canceled - added {len(already)} items before cancel'} return {'status': 'ok', 'msg': f'Canceled - added {len(already)} items before cancel'}
etr["_type"] = "video" etr["_type"] = "video"
@@ -728,6 +730,7 @@ class DownloadQueue:
subtitle_language, subtitle_language,
subtitle_mode, subtitle_mode,
already, already,
_add_gen,
) )
) )
if any(res['status'] == 'error' for res in results): if any(res['status'] == 'error' for res in results):
@@ -773,6 +776,7 @@ class DownloadQueue:
subtitle_language="en", subtitle_language="en",
subtitle_mode="prefer_manual", subtitle_mode="prefer_manual",
already=None, already=None,
_add_gen=None,
): ):
log.info( log.info(
f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} ' f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} '
@@ -780,7 +784,7 @@ class DownloadQueue:
f'{subtitle_format=} {subtitle_language=} {subtitle_mode=}' f'{subtitle_format=} {subtitle_language=} {subtitle_mode=}'
) )
if already is None: if already is None:
self._add_canceled = False _add_gen = self._add_generation
already = set() if already is None else already already = set() if already is None else already
if url in already: if url in already:
log.info('recursion detected, skipping') log.info('recursion detected, skipping')
@@ -805,6 +809,7 @@ class DownloadQueue:
subtitle_language, subtitle_language,
subtitle_mode, subtitle_mode,
already, already,
_add_gen,
) )
async def start_pending(self, ids): async def start_pending(self, ids):

View File

@@ -98,7 +98,12 @@
name="addUrl" name="addUrl"
[(ngModel)]="addUrl" [(ngModel)]="addUrl"
[disabled]="addInProgress || downloads.loading"> [disabled]="addInProgress || downloads.loading">
@if (addInProgress) { @if (addInProgress && cancelRequested) {
<button class="btn btn-warning btn-lg px-3" type="button" disabled>
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
Canceling...
</button>
} @else if (addInProgress) {
<button class="btn btn-danger btn-lg px-3" type="button" (click)="cancelAdding()"> <button class="btn btn-danger btn-lg px-3" type="button" (click)="cancelAdding()">
<fa-icon [icon]="faTimesCircle" class="me-1" /> Cancel <fa-icon [icon]="faTimesCircle" class="me-1" /> Cancel
</button> </button>
@@ -426,15 +431,9 @@
</table> </table>
</div> </div>
<div class="metube-section-header d-flex align-items-center"> <div class="metube-section-header">Completed</div>
Completed
<button type="button" class="btn btn-sm btn-outline-secondary ms-3"
(click)="toggleSortOrder()"
ngbTooltip="{{ sortAscending ? 'Oldest first' : 'Newest first' }}">
<fa-icon [icon]="sortAscending ? faSortAmountUp : faSortAmountDown" />
</button>
</div>
<div class="px-2 py-3 border-bottom"> <div class="px-2 py-3 border-bottom">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" (click)="toggleSortOrder()" ngbTooltip="{{ sortAscending ? 'Oldest first' : 'Newest first' }}"><fa-icon [icon]="sortAscending ? faSortAmountUp : faSortAmountDown" />&nbsp; {{ sortAscending ? 'Oldest first' : 'Newest first' }}</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt" />&nbsp; Clear selected</button> <button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt" />&nbsp; Clear selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearCompleted (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle" />&nbsp; Clear completed</button> <button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearCompleted (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle" />&nbsp; Clear completed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearFailed (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle" />&nbsp; Clear failed</button> <button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearFailed (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle" />&nbsp; Clear failed</button>
@@ -454,7 +453,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (entry of sortedDone(); track entry[1].id) { @for (entry of cachedSortedDone; track entry[1].id) {
<tr [class.disabled]='entry[1].deleting'> <tr [class.disabled]='entry[1].deleting'>
<td> <td>
<app-slave-checkbox [id]="entry[0]" [master]="doneMasterCheckboxRef" [checkable]="entry[1]" /> <app-slave-checkbox [id]="entry[0]" [master]="doneMasterCheckboxRef" [checkable]="entry[1]" />
@@ -465,7 +464,12 @@
<fa-icon [icon]="faCheckCircle" class="text-success" /> <fa-icon [icon]="faCheckCircle" class="text-success" />
} }
@if (entry[1].status === 'error') { @if (entry[1].status === 'error') {
<fa-icon [icon]="faTimesCircle" class="text-danger" style="cursor: pointer;" (click)="toggleErrorDetail(entry[0])" /> <button type="button" class="btn btn-link p-0"
(click)="toggleErrorDetail(entry[0])"
[attr.aria-label]="'Toggle error details for ' + entry[1].title"
[attr.aria-expanded]="isErrorExpanded(entry[0])">
<fa-icon [icon]="faTimesCircle" class="text-danger" />
</button>
} }
</div> </div>
<span ngbTooltip="{{buildResultItemTooltip(entry[1])}}">@if (!!entry[1].filename) { <span ngbTooltip="{{buildResultItemTooltip(entry[1])}}">@if (!!entry[1].filename) {
@@ -494,9 +498,13 @@
<div class="text-muted" style="word-break: break-all;"><strong>URL:</strong> {{entry[1].url}}</div> <div class="text-muted" style="word-break: break-all;"><strong>URL:</strong> {{entry[1].url}}</div>
</div> </div>
<button type="button" class="btn btn-sm btn-outline-danger ms-2 flex-shrink-0" <button type="button" class="btn btn-sm btn-outline-danger ms-2 flex-shrink-0"
(click)="copyErrorMessage(entry[1]); $event.stopPropagation()" (click)="copyErrorMessage(entry[0], entry[1]); $event.stopPropagation()"
ngbTooltip="Copy error details to clipboard"> ngbTooltip="Copy error details to clipboard">
<fa-icon [icon]="faCopy" /> @if (lastCopiedErrorId === entry[0]) {
<span class="text-success">Copied!</span>
} @else {
<fa-icon [icon]="faCopy" />
}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -53,6 +53,7 @@ export class App implements AfterViewInit, OnInit {
subtitleLanguage: string; subtitleLanguage: string;
subtitleMode: string; subtitleMode: string;
addInProgress = false; addInProgress = false;
cancelRequested = false;
themes: Theme[] = Themes; themes: Theme[] = Themes;
activeTheme: Theme | undefined; activeTheme: Theme | undefined;
customDirs$!: Observable<string[]>; customDirs$!: Observable<string[]>;
@@ -68,6 +69,8 @@ export class App implements AfterViewInit, OnInit {
isAdvancedOpen = false; isAdvancedOpen = false;
sortAscending = false; sortAscending = false;
expandedErrors: Set<string> = new Set(); expandedErrors: Set<string> = new Set();
cachedSortedDone: [string, Download][] = [];
lastCopiedErrorId: string | null = null;
// Download metrics // Download metrics
activeDownloads = 0; activeDownloads = 0;
@@ -193,6 +196,7 @@ export class App implements AfterViewInit, OnInit {
}); });
this.downloads.doneChanged.subscribe(() => { this.downloads.doneChanged.subscribe(() => {
this.updateMetrics(); this.updateMetrics();
this.rebuildSortedDone();
}); });
// Subscribe to real-time updates // Subscribe to real-time updates
this.downloads.updated.subscribe(() => { 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); 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.addInProgress = true;
this.cancelRequested = false;
this.downloads.add(url, quality, format, folder, customNamePrefix, playlistItemLimit, autoStart, splitByChapters, chapterTemplate, subtitleFormat, subtitleLanguage, subtitleMode).subscribe((status: Status) => { 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}`); alert(`Error adding URL: ${status.msg}`);
} else { } else if (status.status !== 'error') {
this.addUrl = ''; this.addUrl = '';
} }
this.addInProgress = false; this.addInProgress = false;
this.cancelRequested = false;
}); });
} }
cancelAdding() { cancelAdding() {
this.cancelRequested = true;
this.downloads.cancelAdd().subscribe({ this.downloads.cancelAdd().subscribe({
next: () => { this.addInProgress = false; }, error: (err) => {
error: () => { this.addInProgress = false; } console.error('Failed to cancel adding:', err?.message || err);
}
}); });
} }
@@ -715,20 +723,18 @@ export class App implements AfterViewInit, OnInit {
toggleSortOrder() { toggleSortOrder() {
this.sortAscending = !this.sortAscending; this.sortAscending = !this.sortAscending;
this.cookieService.set('metube_sort_ascending', this.sortAscending ? 'true' : 'false', { expires: 3650 }); this.cookieService.set('metube_sort_ascending', this.sortAscending ? 'true' : 'false', { expires: 3650 });
this.rebuildSortedDone();
} }
sortedDone(): [string, Download][] { private rebuildSortedDone() {
const result: [string, Download][] = []; const result: [string, Download][] = [];
this.downloads.done.forEach((dl, key) => { this.downloads.done.forEach((dl, key) => {
result.push([key, dl]); result.push([key, dl]);
}); });
result.sort((a, b) => { if (!this.sortAscending) {
const tsA = (a[1] as any).timestamp || 0; result.reverse();
const tsB = (b[1] as any).timestamp || 0; }
const cmp = tsA < tsB ? -1 : tsA > tsB ? 1 : 0; this.cachedSortedDone = result;
return this.sortAscending ? cmp : -cmp;
});
return result;
} }
toggleErrorDetail(id: string) { toggleErrorDetail(id: string) {
@@ -736,13 +742,40 @@ export class App implements AfterViewInit, OnInit {
else this.expandedErrors.add(id); else this.expandedErrors.add(id);
} }
copyErrorMessage(download: Download) { copyErrorMessage(id: string, download: Download) {
const parts: string[] = []; const parts: string[] = [];
if (download.title) parts.push(`Title: ${download.title}`); if (download.title) parts.push(`Title: ${download.title}`);
if (download.url) parts.push(`URL: ${download.url}`); if (download.url) parts.push(`URL: ${download.url}`);
if (download.msg) parts.push(`Message: ${download.msg}`); if (download.msg) parts.push(`Message: ${download.msg}`);
if (download.error) parts.push(`Error: ${download.error}`); 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 { isErrorExpanded(id: string): boolean {

View File

@@ -209,7 +209,7 @@ export class DownloadsService {
return Array.from(this.queue.values()).map(download => download.url); return Array.from(this.queue.values()).map(download => download.url);
} }
public cancelAdd() { public cancelAdd() {
return this.http.post<any>('cancel-add', {}).pipe( return this.http.post<Status>('cancel-add', {}).pipe(
catchError(this.handleHTTPError) catchError(this.handleHTTPError)
); );
} }