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 @@
-
+
+
@@ -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)
);
}
|