diff --git a/src/__tests__/download.test.ts b/src/__tests__/download.test.ts index 9a4d9dc..d3d5f4e 100644 --- a/src/__tests__/download.test.ts +++ b/src/__tests__/download.test.ts @@ -874,4 +874,283 @@ describe('DownloadService', () => { expect(result.filePath).toBeDefined(); }); }); + + describe('downloadItem — SponsorBlock arg construction', () => { + function setupForArgs(deps: ReturnType) { + const outputPath = join(tmpDir, 'media', 'youtube', 'Test Channel', 'Test Video Title.mp4'); + mkdirSync(join(tmpDir, 'media', 'youtube', 'Test Channel'), { recursive: true }); + writeFileSync(outputPath, 'data'); + execYtDlpMock.mockResolvedValueOnce({ stdout: outputPath, stderr: '', exitCode: 0 }); + statMock.mockResolvedValueOnce({ size: 1000 }); + return outputPath; + } + + it('produces --sponsorblock-remove with comma-separated categories', async () => { + const deps = createMockDeps(); + const service = new DownloadService( + db, deps.rateLimiter, deps.fileOrganizer, + deps.qualityAnalyzer, deps.cookieManager + ); + setupForArgs(deps); + + const profile: FormatProfile = { + id: 20, name: 'SB Test', videoResolution: null, audioCodec: null, audioBitrate: null, + containerFormat: null, isDefault: false, subtitleLanguages: null, embedSubtitles: false, + embedChapters: false, embedThumbnail: false, + sponsorBlockRemove: 'sponsor,selfpromo', + outputTemplate: null, createdAt: '', updatedAt: '', + }; + + await service.downloadItem(testContentItem, testChannel, profile); + + const args = execYtDlpMock.mock.calls[0][0] as string[]; + const sbIdx = args.indexOf('--sponsorblock-remove'); + expect(sbIdx).toBeGreaterThanOrEqual(0); + expect(args[sbIdx + 1]).toBe('sponsor,selfpromo'); + }); + + it('does not include --sponsorblock-remove when sponsorBlockRemove is null', async () => { + const deps = createMockDeps(); + const service = new DownloadService( + db, deps.rateLimiter, deps.fileOrganizer, + deps.qualityAnalyzer, deps.cookieManager + ); + setupForArgs(deps); + + const profile: FormatProfile = { + id: 21, name: 'No SB', videoResolution: null, audioCodec: null, audioBitrate: null, + containerFormat: null, isDefault: false, subtitleLanguages: null, embedSubtitles: false, + embedChapters: false, embedThumbnail: false, + sponsorBlockRemove: null, + outputTemplate: null, createdAt: '', updatedAt: '', + }; + + await service.downloadItem(testContentItem, testChannel, profile); + + const args = execYtDlpMock.mock.calls[0][0] as string[]; + expect(args).not.toContain('--sponsorblock-remove'); + }); + + it('does not include --sponsorblock-remove when value is empty/whitespace', async () => { + const deps = createMockDeps(); + const service = new DownloadService( + db, deps.rateLimiter, deps.fileOrganizer, + deps.qualityAnalyzer, deps.cookieManager + ); + setupForArgs(deps); + + const profile: FormatProfile = { + id: 22, name: 'Empty SB', videoResolution: null, audioCodec: null, audioBitrate: null, + containerFormat: null, isDefault: false, subtitleLanguages: null, embedSubtitles: false, + embedChapters: false, embedThumbnail: false, + sponsorBlockRemove: ' ', + outputTemplate: null, createdAt: '', updatedAt: '', + }; + + await service.downloadItem(testContentItem, testChannel, profile); + + const args = execYtDlpMock.mock.calls[0][0] as string[]; + expect(args).not.toContain('--sponsorblock-remove'); + }); + + it('handles all SponsorBlock category types', async () => { + const deps = createMockDeps(); + const service = new DownloadService( + db, deps.rateLimiter, deps.fileOrganizer, + deps.qualityAnalyzer, deps.cookieManager + ); + setupForArgs(deps); + + const allCategories = 'sponsor,selfpromo,interaction,intro,outro,preview,music_offtopic,filler'; + const profile: FormatProfile = { + id: 23, name: 'All SB', videoResolution: null, audioCodec: null, audioBitrate: null, + containerFormat: null, isDefault: false, subtitleLanguages: null, embedSubtitles: false, + embedChapters: false, embedThumbnail: false, + sponsorBlockRemove: allCategories, + outputTemplate: null, createdAt: '', updatedAt: '', + }; + + await service.downloadItem(testContentItem, testChannel, profile); + + const args = execYtDlpMock.mock.calls[0][0] as string[]; + const sbIdx = args.indexOf('--sponsorblock-remove'); + expect(sbIdx).toBeGreaterThanOrEqual(0); + expect(args[sbIdx + 1]).toBe(allCategories); + }); + }); + + describe('downloadItem — subtitle arg construction', () => { + function setupForArgs(deps: ReturnType) { + const outputPath = join(tmpDir, 'media', 'youtube', 'Test Channel', 'Test Video Title.mp4'); + mkdirSync(join(tmpDir, 'media', 'youtube', 'Test Channel'), { recursive: true }); + writeFileSync(outputPath, 'data'); + execYtDlpMock.mockResolvedValueOnce({ stdout: outputPath, stderr: '', exitCode: 0 }); + statMock.mockResolvedValueOnce({ size: 1000 }); + return outputPath; + } + + it('produces --write-subs and --sub-langs when subtitleLanguages is set', async () => { + const deps = createMockDeps(); + const service = new DownloadService( + db, deps.rateLimiter, deps.fileOrganizer, + deps.qualityAnalyzer, deps.cookieManager + ); + setupForArgs(deps); + + const profile: FormatProfile = { + id: 30, name: 'Sub Test', videoResolution: null, audioCodec: null, audioBitrate: null, + containerFormat: null, isDefault: false, + subtitleLanguages: 'en,es', + embedSubtitles: false, embedChapters: false, embedThumbnail: false, + sponsorBlockRemove: null, outputTemplate: null, createdAt: '', updatedAt: '', + }; + + await service.downloadItem(testContentItem, testChannel, profile); + + const args = execYtDlpMock.mock.calls[0][0] as string[]; + expect(args).toContain('--write-subs'); + const slIdx = args.indexOf('--sub-langs'); + expect(slIdx).toBeGreaterThanOrEqual(0); + expect(args[slIdx + 1]).toBe('en,es'); + // embedSubtitles is false, so no --embed-subs + expect(args).not.toContain('--embed-subs'); + }); + + it('produces --embed-subs when embedSubtitles=true AND subtitleLanguages is set', async () => { + const deps = createMockDeps(); + const service = new DownloadService( + db, deps.rateLimiter, deps.fileOrganizer, + deps.qualityAnalyzer, deps.cookieManager + ); + setupForArgs(deps); + + const profile: FormatProfile = { + id: 31, name: 'Embed Sub', videoResolution: null, audioCodec: null, audioBitrate: null, + containerFormat: null, isDefault: false, + subtitleLanguages: 'en,es', + embedSubtitles: true, + embedChapters: false, embedThumbnail: false, + sponsorBlockRemove: null, outputTemplate: null, createdAt: '', updatedAt: '', + }; + + await service.downloadItem(testContentItem, testChannel, profile); + + const args = execYtDlpMock.mock.calls[0][0] as string[]; + expect(args).toContain('--write-subs'); + expect(args).toContain('--sub-langs'); + expect(args).toContain('--embed-subs'); + }); + + it('does NOT produce --embed-subs when embedSubtitles=true but subtitleLanguages is null', async () => { + const deps = createMockDeps(); + const service = new DownloadService( + db, deps.rateLimiter, deps.fileOrganizer, + deps.qualityAnalyzer, deps.cookieManager + ); + setupForArgs(deps); + + const profile: FormatProfile = { + id: 32, name: 'No Lang', videoResolution: null, audioCodec: null, audioBitrate: null, + containerFormat: null, isDefault: false, + subtitleLanguages: null, + embedSubtitles: true, + embedChapters: false, embedThumbnail: false, + sponsorBlockRemove: null, outputTemplate: null, createdAt: '', updatedAt: '', + }; + + await service.downloadItem(testContentItem, testChannel, profile); + + const args = execYtDlpMock.mock.calls[0][0] as string[]; + expect(args).not.toContain('--write-subs'); + expect(args).not.toContain('--sub-langs'); + expect(args).not.toContain('--embed-subs'); + }); + + it('does not produce subtitle args when no format profile', async () => { + const deps = createMockDeps(); + const service = new DownloadService( + db, deps.rateLimiter, deps.fileOrganizer, + deps.qualityAnalyzer, deps.cookieManager + ); + setupForArgs(deps); + + await service.downloadItem(testContentItem, testChannel); + + const args = execYtDlpMock.mock.calls[0][0] as string[]; + expect(args).not.toContain('--write-subs'); + expect(args).not.toContain('--sub-langs'); + expect(args).not.toContain('--embed-subs'); + }); + + it('handles single subtitle language', async () => { + const deps = createMockDeps(); + const service = new DownloadService( + db, deps.rateLimiter, deps.fileOrganizer, + deps.qualityAnalyzer, deps.cookieManager + ); + setupForArgs(deps); + + const profile: FormatProfile = { + id: 33, name: 'Single Lang', videoResolution: null, audioCodec: null, audioBitrate: null, + containerFormat: null, isDefault: false, + subtitleLanguages: 'en', + embedSubtitles: true, + embedChapters: false, embedThumbnail: false, + sponsorBlockRemove: null, outputTemplate: null, createdAt: '', updatedAt: '', + }; + + await service.downloadItem(testContentItem, testChannel, profile); + + const args = execYtDlpMock.mock.calls[0][0] as string[]; + expect(args).toContain('--write-subs'); + const slIdx = args.indexOf('--sub-langs'); + expect(args[slIdx + 1]).toBe('en'); + expect(args).toContain('--embed-subs'); + }); + }); + + describe('downloadItem — combined SponsorBlock + subtitle args', () => { + it('includes both SponsorBlock and subtitle args when both are configured', async () => { + const deps = createMockDeps(); + const service = new DownloadService( + db, deps.rateLimiter, deps.fileOrganizer, + deps.qualityAnalyzer, deps.cookieManager + ); + + const outputPath = join(tmpDir, 'media', 'youtube', 'Test Channel', 'Test Video Title.mp4'); + mkdirSync(join(tmpDir, 'media', 'youtube', 'Test Channel'), { recursive: true }); + writeFileSync(outputPath, 'data'); + execYtDlpMock.mockResolvedValueOnce({ stdout: outputPath, stderr: '', exitCode: 0 }); + statMock.mockResolvedValueOnce({ size: 1000 }); + + const profile: FormatProfile = { + id: 40, name: 'Full Features', videoResolution: '1080p', audioCodec: null, audioBitrate: null, + containerFormat: 'mkv', isDefault: false, + subtitleLanguages: 'en,es,fr', + embedSubtitles: true, + embedChapters: true, embedThumbnail: true, + sponsorBlockRemove: 'sponsor,selfpromo,intro,outro', + outputTemplate: null, createdAt: '', updatedAt: '', + }; + + await service.downloadItem(testContentItem, testChannel, profile); + + const args = execYtDlpMock.mock.calls[0][0] as string[]; + + // Subtitle args + expect(args).toContain('--write-subs'); + const slIdx = args.indexOf('--sub-langs'); + expect(args[slIdx + 1]).toBe('en,es,fr'); + expect(args).toContain('--embed-subs'); + + // SponsorBlock args + const sbIdx = args.indexOf('--sponsorblock-remove'); + expect(sbIdx).toBeGreaterThanOrEqual(0); + expect(args[sbIdx + 1]).toBe('sponsor,selfpromo,intro,outro'); + + // Chapter + thumbnail embedding + expect(args).toContain('--embed-chapters'); + expect(args).toContain('--embed-thumbnail'); + }); + }); });