mirror of
https://github.com/alexta69/metube.git
synced 2026-03-18 14:33:50 +00:00
enhance playlist addition cancellation and improve error handling UI
This commit is contained in:
13
app/ytdl.py
13
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):
|
||||
|
||||
@@ -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" /> {{ 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" /> 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" /> 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" /> 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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user