mirror of
https://github.com/alexta69/metube.git
synced 2026-03-18 22:43:51 +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.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):
|
||||||
|
|||||||
@@ -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" /> {{ 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 #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 #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>
|
<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>
|
</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">
|
||||||
|
@if (lastCopiedErrorId === entry[0]) {
|
||||||
|
<span class="text-success">Copied!</span>
|
||||||
|
} @else {
|
||||||
<fa-icon [icon]="faCopy" />
|
<fa-icon [icon]="faCopy" />
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user