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:
Alex Shnitman
2026-07-12 08:08:14 +03:00
parent e2c777842e
commit 3ea4732c5d
21 changed files with 1392 additions and 166 deletions
+2 -2
View File
@@ -699,7 +699,7 @@
</tr>
</thead>
<tbody>
@for (download of downloads.queue | keyvalue: asIsOrder; track download.value.id) {
@for (download of downloads.queue | keyvalue: asIsOrder; track download.key) {
<tr [class.disabled]='download.value.deleting'>
<td>
<app-item-checkbox [id]="download.key" [master]="queueMasterCheckboxRef" [checkable]="download.value" />
@@ -769,7 +769,7 @@
</tr>
</thead>
<tbody>
@for (entry of cachedSortedDone; track entry[1].id) {
@for (entry of cachedSortedDone; track entry[0]) {
<tr [class.disabled]='entry[1].deleting'>
<td>
<app-item-checkbox [id]="entry[0]" [master]="doneMasterCheckboxRef" [checkable]="entry[1]" />
+6
View File
@@ -139,6 +139,12 @@ describe('App', () => {
expect(app).toBeTruthy();
});
it('asIsOrder returns a stable comparator value (insertion order preserved)', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app.asIsOrder()).toBe(0);
});
it('hides manual override input when disabled', () => {
const fixture = TestBed.createComponent(App);
fixture.componentInstance.isAdvancedOpen = true;
+36 -16
View File
@@ -351,13 +351,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.colorSchemeMediaQuery.removeEventListener('change', this.onColorSchemeChanged);
}
// workaround to allow fetching of Map values in the order they were inserted
// https://github.com/angular/angular/issues/31420
// keyvalue comparator that preserves insertion order (Angular's keyvalue
// pipe sorts by key by default): https://github.com/angular/angular/issues/31420
asIsOrder() {
return 1;
return 0;
}
qualityChanged() {
@@ -430,8 +427,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.downloads.configurationChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (config: any) => {
const playlistItemLimit = config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT'];
if (playlistItemLimit !== '0') {
const playlistItemLimit = parseInt(String(config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT'] ?? '0'), 10);
if (!Number.isNaN(playlistItemLimit) && playlistItemLimit > 0) {
this.playlistItemLimit = playlistItemLimit;
}
// Set chapter template from backend config if not already set by cookie
@@ -526,6 +523,14 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
return status?.status === 'error' ? status.msg || null : null;
}
private handleActionResult(res: unknown, fallbackMsg: string) {
const error = this.getStatusError(res);
if (error) {
this.toasts.error(error || fallbackMsg);
}
this.cdr.markForCheck();
}
private refreshSubscriptionsWithAlert() {
this.subscriptionsSvc.refreshList().pipe(takeUntilDestroyed(this.destroyRef)).subscribe((refreshRes) => {
const error = this.getStatusError(refreshRes);
@@ -1085,6 +1090,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
if (status.status === 'error' && !this.cancelRequested) {
this.toasts.error(`Error adding URL: ${status.msg}`);
} else if (status.status !== 'error') {
// e.g. "Already in queue: ..." when the backend skipped a duplicate.
if (status.msg) {
this.toasts.info(status.msg);
}
this.addUrl = '';
}
this.resetAddState();
@@ -1113,7 +1122,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
}
downloadItemByKey(id: string) {
this.downloads.startById([id]).subscribe();
this.downloads.startById([id]).subscribe((res) => this.handleActionResult(res, 'Start download failed'));
}
liveCountdownSeconds(download: Download): number | null {
@@ -1137,7 +1146,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
}
retryDownload(key: string, download: Download) {
this.addDownload({
const payload = this.buildAddPayload({
url: download.url,
downloadType: download.download_type,
codec: download.codec,
@@ -1158,27 +1167,38 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
clipStart: download.clip_start != null ? String(download.clip_start) : '',
clipEnd: download.clip_end != null ? String(download.clip_end) : '',
});
this.downloads.delById('done', [key]).subscribe();
// Only remove the done-list record once the retry is confirmed queued —
// deleting it eagerly would silently lose history if the re-add fails.
this.downloads.add(payload)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((status: Status) => {
if (status.status === 'error') {
this.toasts.error(`Error retrying ${download.title}: ${status.msg}`);
this.cdr.markForCheck();
return;
}
this.downloads.delById('done', [key]).subscribe();
});
}
delDownload(where: State, id: string) {
this.downloads.delById(where, [id]).subscribe();
this.downloads.delById(where, [id]).subscribe((res) => this.handleActionResult(res, 'Delete failed'));
}
startSelectedDownloads(where: State){
this.downloads.startByFilter(where, dl => !!dl.checked).subscribe();
this.downloads.startByFilter(where, dl => !!dl.checked).subscribe((res) => this.handleActionResult(res, 'Start download failed'));
}
delSelectedDownloads(where: State) {
this.downloads.delByFilter(where, dl => !!dl.checked).subscribe();
this.downloads.delByFilter(where, dl => !!dl.checked).subscribe((res) => this.handleActionResult(res, 'Delete failed'));
}
clearCompletedDownloads() {
this.downloads.delByFilter('done', dl => dl.status === 'finished').subscribe();
this.downloads.delByFilter('done', dl => dl.status === 'finished').subscribe((res) => this.handleActionResult(res, 'Clear completed failed'));
}
clearFailedDownloads() {
this.downloads.delByFilter('done', dl => dl.status === 'error').subscribe();
this.downloads.delByFilter('done', dl => dl.status === 'error').subscribe((res) => this.handleActionResult(res, 'Clear failed downloads failed'));
}
retryFailedDownloads() {
+7 -6
View File
@@ -10,15 +10,16 @@ describe('FileSizePipe', () => {
it('formats bytes and larger units', () => {
const pipe = new FileSizePipe();
expect(pipe.transform(500)).toContain('Bytes');
expect(pipe.transform(1000)).toContain('KB');
expect(pipe.transform(1000 * 1000)).toContain('MB');
expect(pipe.transform(1000 ** 3)).toContain('GB');
expect(pipe.transform(1000)).toContain('Bytes');
expect(pipe.transform(1024)).toContain('KB');
expect(pipe.transform(1024 ** 2)).toContain('MB');
expect(pipe.transform(1024 ** 3)).toContain('GB');
});
it('handles boundaries between units', () => {
const pipe = new FileSizePipe();
expect(pipe.transform(999)).toContain('Bytes');
expect(pipe.transform(1000)).toContain('KB');
expect(pipe.transform(1001)).toContain('KB');
expect(pipe.transform(1023)).toContain('Bytes');
expect(pipe.transform(1024)).toContain('KB');
expect(pipe.transform(1025)).toContain('KB');
});
});
+3 -2
View File
@@ -8,9 +8,10 @@ export class FileSizePipe implements PipeTransform {
if (isNaN(value) || value === 0) return '0 Bytes';
const units = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const unitIndex = Math.floor(Math.log(value) / Math.log(1000)); // Use 1000 for common units
const k = 1024; // Matches SpeedPipe's base so file sizes and transfer speeds agree.
const unitIndex = Math.floor(Math.log(value) / Math.log(k));
const unitValue = value / Math.pow(1000, unitIndex);
const unitValue = value / Math.pow(k, unitIndex);
return `${unitValue.toFixed(2)} ${units[unitIndex]}`;
}
}
@@ -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',
+27 -4
View File
@@ -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) {