From b4d730d42f85b0f0d29bd65f65a5bd3ba28b5a3c Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 06:16:03 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Wired=20NFO=20generation=20into=20Downl?= =?UTF-8?q?oadService=20with=20feature=20flag=20check=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/services/download.ts" - "src/__tests__/download.test.ts" GSD-Task: S05/T03 --- src/__tests__/download.test.ts | 115 ++++++++++++++++++++++++++++++++- src/services/download.ts | 28 ++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/src/__tests__/download.test.ts b/src/__tests__/download.test.ts index 35f68dd..3a22e2f 100644 --- a/src/__tests__/download.test.ts +++ b/src/__tests__/download.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'; +import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { initDatabaseAsync, closeDatabase } from '../db/index'; @@ -39,6 +39,16 @@ vi.mock('node:fs/promises', async (importOriginal) => { }; }); +// Mock getAppSetting for NFO feature flag +const getAppSettingMock = vi.fn(); +vi.mock('../db/repositories/system-config-repository', async (importOriginal) => { + const actual = await importOriginal() as Record; + return { + ...actual, + getAppSetting: (...args: unknown[]) => getAppSettingMock(...args), + }; +}); + // ── Test Helpers ── let tmpDir: string; @@ -761,4 +771,107 @@ describe('DownloadService', () => { expect(args).not.toContain('--audio-quality'); }); }); + + describe('downloadItem — NFO sidecar generation', () => { + function setupSuccessfulDownload(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, 'fake video data'); + + execYtDlpMock.mockResolvedValueOnce({ + stdout: outputPath, + stderr: '', + exitCode: 0, + }); + statMock.mockResolvedValueOnce({ size: 10_000_000 }); + + return outputPath; + } + + it('writes .nfo sidecar when app.nfo_enabled is "true"', async () => { + const deps = createMockDeps(); + const service = new DownloadService( + db, deps.rateLimiter, deps.fileOrganizer, + deps.qualityAnalyzer, deps.cookieManager + ); + + const outputPath = setupSuccessfulDownload(deps); + getAppSettingMock.mockResolvedValueOnce('true'); + + await service.downloadItem(testContentItem, testChannel); + + // NFO file should exist alongside the media file + const nfoPath = outputPath.replace(/\.mp4$/, '.nfo'); + expect(existsSync(nfoPath)).toBe(true); + + // Validate NFO content + const nfoContent = readFileSync(nfoPath, 'utf-8'); + expect(nfoContent).toContain(''); + expect(nfoContent).toContain('Test Video Title'); + expect(nfoContent).toContain('Test Channel'); + }); + + it('does not write .nfo when app.nfo_enabled is not "true"', async () => { + const deps = createMockDeps(); + const service = new DownloadService( + db, deps.rateLimiter, deps.fileOrganizer, + deps.qualityAnalyzer, deps.cookieManager + ); + + const outputPath = setupSuccessfulDownload(deps); + getAppSettingMock.mockResolvedValueOnce(null); // Not set + + await service.downloadItem(testContentItem, testChannel); + + const nfoPath = outputPath.replace(/\.mp4$/, '.nfo'); + expect(existsSync(nfoPath)).toBe(false); + }); + + it('does not write .nfo when app.nfo_enabled is "false"', async () => { + const deps = createMockDeps(); + const service = new DownloadService( + db, deps.rateLimiter, deps.fileOrganizer, + deps.qualityAnalyzer, deps.cookieManager + ); + + const outputPath = setupSuccessfulDownload(deps); + getAppSettingMock.mockResolvedValueOnce('false'); + + await service.downloadItem(testContentItem, testChannel); + + const nfoPath = outputPath.replace(/\.mp4$/, '.nfo'); + expect(existsSync(nfoPath)).toBe(false); + }); + + it('does not fail the download when NFO generation throws', async () => { + const deps = createMockDeps(); + const service = new DownloadService( + db, deps.rateLimiter, deps.fileOrganizer, + deps.qualityAnalyzer, deps.cookieManager + ); + + setupSuccessfulDownload(deps); + getAppSettingMock.mockRejectedValueOnce(new Error('DB read failed')); + + // Download should still succeed + const result = await service.downloadItem(testContentItem, testChannel); + expect(result.status).toBe('downloaded'); + }); + + it('still completes download successfully even with NFO enabled', async () => { + const deps = createMockDeps(); + const service = new DownloadService( + db, deps.rateLimiter, deps.fileOrganizer, + deps.qualityAnalyzer, deps.cookieManager + ); + + setupSuccessfulDownload(deps); + getAppSettingMock.mockResolvedValueOnce('true'); + + const result = await service.downloadItem(testContentItem, testChannel); + expect(result.status).toBe('downloaded'); + expect(result.filePath).toBeDefined(); + }); + }); }); diff --git a/src/services/download.ts b/src/services/download.ts index d954469..ebfd34a 100644 --- a/src/services/download.ts +++ b/src/services/download.ts @@ -5,6 +5,8 @@ import type { LibSQLDatabase } from 'drizzle-orm/libsql'; import type * as schema from '../db/schema/index'; import { execYtDlp, spawnYtDlp, YtDlpError, classifyYtDlpError } from '../sources/yt-dlp'; import { updateContentItem } from '../db/repositories/content-repository'; +import { getAppSetting, APP_NFO_ENABLED } from '../db/repositories/system-config-repository'; +import { generateNfo, writeNfoFile } from './nfo-generator'; import { parseProgressLine } from './progress-parser'; import type { DownloadEventBus } from './event-bus'; import type { RateLimiter } from './rate-limiter'; @@ -137,6 +139,9 @@ export class DownloadService { this.rateLimiter.reportSuccess(platform as Platform); + // Generate NFO sidecar if enabled + await this.maybeWriteNfo(contentItem, channel, finalPath, logPrefix); + // Emit download:complete event this.eventBus?.emitDownload('download:complete', { contentItemId: contentItem.id }); @@ -457,6 +462,29 @@ export class DownloadService { if (url.includes('soundcloud.com')) return 'soundcloud'; return 'generic'; } + + /** + * Write an NFO sidecar file alongside the downloaded media if the feature is enabled. + * NFO generation is best-effort — failure is logged but never fails the download. + */ + private async maybeWriteNfo( + contentItem: ContentItem, + channel: Channel | null, + mediaFilePath: string, + logPrefix: string + ): Promise { + try { + const nfoEnabled = await getAppSetting(this.db, APP_NFO_ENABLED); + if (nfoEnabled !== 'true') return; + + const nfoXml = generateNfo(contentItem, channel); + const nfoPath = await writeNfoFile(nfoXml, mediaFilePath); + console.log(`${logPrefix} nfo written path="${nfoPath}"`); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`${logPrefix} nfo generation failed (non-fatal): ${msg}`); + } + } } // ── Helpers ──