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
@@ -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',