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.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):

View File

@@ -98,7 +98,12 @@
name="addUrl"
[(ngModel)]="addUrl"
[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()">
<fa-icon [icon]="faTimesCircle" class="me-1" /> Cancel
</button>
@@ -426,15 +431,9 @@
</table>
</div>
<div class="metube-section-header d-flex align-items-center">
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="metube-section-header">Completed</div>
<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 #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>
@@ -454,7 +453,7 @@
</tr>
</thead>
<tbody>
@for (entry of sortedDone(); track entry[1].id) {
@for (entry of cachedSortedDone; track entry[1].id) {
<tr [class.disabled]='entry[1].deleting'>
<td>
<app-slave-checkbox [id]="entry[0]" [master]="doneMasterCheckboxRef" [checkable]="entry[1]" />
@@ -465,7 +464,12 @@
<fa-icon [icon]="faCheckCircle" class="text-success" />
}
@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>
<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>
<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">
<fa-icon [icon]="faCopy" />
@if (lastCopiedErrorId === entry[0]) {
<span class="text-success">Copied!</span>
} @else {
<fa-icon [icon]="faCopy" />
}
</button>
</div>
</div>

View File

@@ -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<string[]>;
@@ -68,6 +69,8 @@ export class App implements AfterViewInit, OnInit {
isAdvancedOpen = false;
sortAscending = false;
expandedErrors: Set<string> = 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 {

View File

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