mirror of
https://github.com/alexta69/metube.git
synced 2026-09-21 21:45:04 +00:00
fix: harden download lifecycle, subscriptions, and UI robustness
Addresses a full-project review. Backend correctness and availability: - ytdl: cancel() only SIGKILLs the child's process group when the child actually became its own group leader, so a race (or failed setpgrp) can no longer kill the whole server; kill the group on cancel and on shutdown to avoid orphaned ffmpeg children - ytdl: dedicated ThreadPoolExecutor for download supervision so active downloads can't starve extract_info / live probes on the default pool - ytdl/main/subscriptions: route fire-and-forget tasks through a bg_tasks helper that keeps a strong ref and logs failures - subscriptions: run flat-playlist extraction in an executor and check feeds with bounded concurrency so one slow feed can't block the loop; set last_checked on failure so broken feeds aren't retried every 60s - main: validate ids on /start & /delete and numeric env vars at startup; return 400 (not 500) on bad subscriptions/update input; serve /history from memory; move get_custom_dirs off the event loop; restrict t= stripping to YouTube hosts; drop double percent-decode in state guard - dl_formats/ytdl: enforce requested caption format via FFmpegSubtitlesConvertor and strip VTT header metadata only in the pre-cue region so real dialogue is preserved - ytdl: throttle progress events, dedup adds against pending, clear filename/size on error and reject out-of-dir trashcan deletes, pin fork start-method on Linux only Frontend: - retry deletes the done record only after a successful re-add - surface HTTP errors for delete/start and reset the deleting flag - ignore late 'updated' events for rows no longer in the queue - track table rows by map key; FileSizePipe uses base-1024 Also: HTTPS-aware Docker healthcheck, dead-code removal, and shared helpers for path-containment and yt-dlp option merging. Adds/updates unit tests throughout (250 backend tests passing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -159,6 +159,61 @@ describe('DownloadsService', () => {
|
||||
req.flush({});
|
||||
});
|
||||
|
||||
it('delById resets deleting flag and emits error status on HTTP failure', () => {
|
||||
const dl: Download = {
|
||||
id: '1',
|
||||
title: 't',
|
||||
url: 'u1',
|
||||
download_type: 'video',
|
||||
quality: 'best',
|
||||
format: 'any',
|
||||
folder: '',
|
||||
custom_name_prefix: '',
|
||||
playlist_item_limit: 0,
|
||||
status: 'finished',
|
||||
msg: '',
|
||||
percent: 0,
|
||||
speed: 0,
|
||||
eta: 0,
|
||||
filename: '',
|
||||
checked: false,
|
||||
deleting: false,
|
||||
};
|
||||
service.queue.set('u1', dl);
|
||||
let queueChangedCount = 0;
|
||||
service.queueChanged.subscribe(() => queueChangedCount++);
|
||||
let result: unknown;
|
||||
let threw = false;
|
||||
|
||||
service.delById('queue', ['u1']).subscribe({
|
||||
next: (res) => { result = res; },
|
||||
error: () => { threw = true; },
|
||||
});
|
||||
expect(dl.deleting).toBe(true);
|
||||
const req = httpMock.expectOne('delete');
|
||||
req.flush({ msg: 'boom' }, { status: 500, statusText: 'Server Error' });
|
||||
|
||||
expect(threw).toBe(false);
|
||||
expect(dl.deleting).toBe(false);
|
||||
expect(queueChangedCount).toBeGreaterThan(0);
|
||||
expect((result as { status: string }).status).toBe('error');
|
||||
});
|
||||
|
||||
it('startById surfaces HTTP errors as a status object instead of throwing', () => {
|
||||
let result: unknown;
|
||||
let threw = false;
|
||||
|
||||
service.startById(['a']).subscribe({
|
||||
next: (res) => { result = res; },
|
||||
error: () => { threw = true; },
|
||||
});
|
||||
const req = httpMock.expectOne('start');
|
||||
req.flush({ msg: 'nope' }, { status: 500, statusText: 'Server Error' });
|
||||
|
||||
expect(threw).toBe(false);
|
||||
expect((result as { status: string }).status).toBe('error');
|
||||
});
|
||||
|
||||
it('handleHTTPError extracts msg from object body', async () => {
|
||||
const err = new HttpErrorResponse({
|
||||
error: { msg: 'bad' },
|
||||
@@ -226,6 +281,15 @@ describe('DownloadsService', () => {
|
||||
expect(updated?.deleting).toBe(true);
|
||||
});
|
||||
|
||||
it('socket updated ignores events for urls not already in the queue', () => {
|
||||
expect(service.queue.has('unknown-url')).toBe(false);
|
||||
socket.emit(
|
||||
'updated',
|
||||
JSON.stringify({ url: 'unknown-url', title: 't', status: 'downloading' }),
|
||||
);
|
||||
expect(service.queue.has('unknown-url')).toBe(false);
|
||||
});
|
||||
|
||||
it('socket completed moves entry to done', () => {
|
||||
service.queue.set('u1', {
|
||||
id: '1',
|
||||
|
||||
@@ -69,8 +69,14 @@ export class DownloadsService {
|
||||
.subscribe((strdata: string) => {
|
||||
const data: Download = JSON.parse(strdata);
|
||||
const dl: Download | undefined = this.queue.get(data.url);
|
||||
data.checked = !!dl?.checked;
|
||||
data.deleting = !!dl?.deleting;
|
||||
// An 'added' event always precedes legitimate updates. If the row is
|
||||
// gone (canceled/completed already processed), this update is stale —
|
||||
// applying it would resurrect a ghost row until the next full refresh.
|
||||
if (!dl) {
|
||||
return;
|
||||
}
|
||||
data.checked = !!dl.checked;
|
||||
data.deleting = !!dl.deleting;
|
||||
this.queue.set(data.url, data);
|
||||
this.updated.next();
|
||||
});
|
||||
@@ -164,7 +170,9 @@ export class DownloadsService {
|
||||
}
|
||||
|
||||
public startById(ids: string[]) {
|
||||
return this.http.post('start', {ids: ids});
|
||||
return this.http.post<Status>('start', {ids: ids}).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
);
|
||||
}
|
||||
|
||||
public delById(where: State, ids: string[]) {
|
||||
@@ -177,7 +185,22 @@ export class DownloadsService {
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.http.post('delete', {where: where, ids: ids});
|
||||
return this.http.post<Status>('delete', {where: where, ids: ids}).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
// Request failed — the rows would otherwise stay disabled forever
|
||||
// with no way to retry, since nothing ever clears `deleting`.
|
||||
if (map) {
|
||||
for (const id of ids) {
|
||||
const obj = map.get(id);
|
||||
if (obj) {
|
||||
obj.deleting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
(where === 'queue' ? this.queueChanged : this.doneChanged).next();
|
||||
return this.handleHTTPError(err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public startByFilter(where: State, filter: (dl: Download) => boolean) {
|
||||
|
||||
Reference in New Issue
Block a user