From 82e966caaf691c71de0a99715266b4b20ec6eb77 Mon Sep 17 00:00:00 2001 From: Alex Shnitman Date: Thu, 20 Aug 2026 10:23:40 +0200 Subject: [PATCH] fix: point audio download links at the directory the server used (closes #533) The server picks the download directory on download_type alone (ytdl.py:1530: AUDIO_DOWNLOAD_DIR if download_type == 'audio'). The UI picked the URL base on download_type *or* a .mp3 extension, so the two disagreed for any mp3 produced under a video-type download -- a postprocessor, a preset, or a record predating download_type. Those files are written to DOWNLOAD_DIR but were linked under audio_download/, giving a 404 on every instance where the two directories differ. The .mp3 clause was not an incomplete audio check to be extended with more extensions; it was a second, conflicting rule. Removing it makes the UI agree with where the file actually is. Same fix in buildChapterDownloadLink, which carried a copy. Test verified by reintroducing the bug: the video-type mp3 case fails with 'audio_download/song.mp3' as expected. Co-Authored-By: Claude Opus 5 --- ui/src/app/app.spec.ts | 60 ++++++++++++++++++++++++++++++++++++++++++ ui/src/app/app.ts | 10 +++++-- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/ui/src/app/app.spec.ts b/ui/src/app/app.spec.ts index 9b4a3a5..5156a3d 100644 --- a/ui/src/app/app.spec.ts +++ b/ui/src/app/app.spec.ts @@ -413,4 +413,64 @@ describe('App', () => { expect(errorSpy).toHaveBeenCalledWith('Subscription name must not be empty'); errorSpy.mockRestore(); }); + // Issue #533: the server picks AUDIO_DOWNLOAD_DIR on download_type alone + // (ytdl.py), so the UI's choice of URL base has to use the same rule. It used + // to also treat any .mp3 as audio, which pointed the link at audio_download/ + // for files the server had written to DOWNLOAD_DIR. + describe('download links follow the server directory rule (#533)', () => { + const makeDownload = (over: Partial): Download => ({ + id: 'vid1', + title: 'Test', + url: 'https://example.com/v', + download_type: 'video', + quality: 'best', + format: 'any', + folder: '', + custom_name_prefix: '', + playlist_item_limit: 0, + status: 'finished', + msg: '', + percent: 100, + speed: 0, + eta: 0, + filename: 'song.mp4', + checked: false, + ...over, + } as Download); + + const appWithDirs = () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + const downloads = TestBed.inject(DownloadsService) as unknown as DownloadsServiceStub; + downloads.configuration['PUBLIC_HOST_URL'] = 'download/'; + downloads.configuration['PUBLIC_HOST_AUDIO_URL'] = 'audio_download/'; + return app; + }; + + it('uses the audio base for an audio download', () => { + const app = appWithDirs(); + const link = app.buildDownloadLink(makeDownload({ download_type: 'audio', filename: 'song.mp3' })); + expect(link).toBe('audio_download/song.mp3'); + }); + + it('uses the video base for an mp3 produced by a video download', () => { + const app = appWithDirs(); + const link = app.buildDownloadLink(makeDownload({ download_type: 'video', filename: 'song.mp3' })); + expect(link).toBe('download/song.mp3'); + }); + + it('uses the video base for a video download', () => { + const app = appWithDirs(); + const link = app.buildDownloadLink(makeDownload({ filename: 'clip.mp4' })); + expect(link).toBe('download/clip.mp4'); + }); + + it('applies the same rule to chapter links', () => { + const app = appWithDirs(); + const dl = makeDownload({ download_type: 'video' }); + expect(app.buildChapterDownloadLink(dl, 'ch1.mp3')).toBe('download/ch1.mp3'); + const audio = makeDownload({ download_type: 'audio' }); + expect(app.buildChapterDownloadLink(audio, 'ch1.mp3')).toBe('audio_download/ch1.mp3'); + }); + }); }); diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index 81b51cc..4aac535 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -1284,7 +1284,12 @@ export class App implements AfterViewInit, OnInit, OnDestroy { buildDownloadLink(download: Download) { let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"]; - if (download.download_type === 'audio' || download.filename.endsWith('.mp3')) { + // Must match the server's directory rule exactly: ytdl.py writes to + // AUDIO_DOWNLOAD_DIR on download_type alone. Treating any .mp3 as audio + // sent the link to audio_download/ for mp3s produced under a video-type + // download (a postprocessor, a preset, or a legacy record), which the + // server had written to DOWNLOAD_DIR -- a 404 whenever the two differ. + if (download.download_type === 'audio') { baseDir = this.downloads.configuration["PUBLIC_HOST_AUDIO_URL"]; } @@ -1382,7 +1387,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy { buildChapterDownloadLink(download: Download, chapterFilename: string) { let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"]; - if (download.download_type === 'audio' || chapterFilename.endsWith('.mp3')) { + // Same server-side rule as buildDownloadLink above. + if (download.download_type === 'audio') { baseDir = this.downloads.configuration["PUBLIC_HOST_AUDIO_URL"]; }