feat: Wired NFO generation into DownloadService with feature flag check…
- "src/services/download.ts" - "src/__tests__/download.test.ts" GSD-Task: S05/T03
This commit is contained in:
parent
7f6f3dcccf
commit
b4d730d42f
2 changed files with 142 additions and 1 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
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 { join } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { initDatabaseAsync, closeDatabase } from '../db/index';
|
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<string, unknown>;
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getAppSetting: (...args: unknown[]) => getAppSettingMock(...args),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// ── Test Helpers ──
|
// ── Test Helpers ──
|
||||||
|
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
|
|
@ -761,4 +771,107 @@ describe('DownloadService', () => {
|
||||||
expect(args).not.toContain('--audio-quality');
|
expect(args).not.toContain('--audio-quality');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('downloadItem — NFO sidecar generation', () => {
|
||||||
|
function setupSuccessfulDownload(deps: ReturnType<typeof createMockDeps>) {
|
||||||
|
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('<?xml version="1.0"');
|
||||||
|
expect(nfoContent).toContain('<episodedetails>');
|
||||||
|
expect(nfoContent).toContain('<title>Test Video Title</title>');
|
||||||
|
expect(nfoContent).toContain('<studio>Test Channel</studio>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import type { LibSQLDatabase } from 'drizzle-orm/libsql';
|
||||||
import type * as schema from '../db/schema/index';
|
import type * as schema from '../db/schema/index';
|
||||||
import { execYtDlp, spawnYtDlp, YtDlpError, classifyYtDlpError } from '../sources/yt-dlp';
|
import { execYtDlp, spawnYtDlp, YtDlpError, classifyYtDlpError } from '../sources/yt-dlp';
|
||||||
import { updateContentItem } from '../db/repositories/content-repository';
|
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 { parseProgressLine } from './progress-parser';
|
||||||
import type { DownloadEventBus } from './event-bus';
|
import type { DownloadEventBus } from './event-bus';
|
||||||
import type { RateLimiter } from './rate-limiter';
|
import type { RateLimiter } from './rate-limiter';
|
||||||
|
|
@ -137,6 +139,9 @@ export class DownloadService {
|
||||||
|
|
||||||
this.rateLimiter.reportSuccess(platform as Platform);
|
this.rateLimiter.reportSuccess(platform as Platform);
|
||||||
|
|
||||||
|
// Generate NFO sidecar if enabled
|
||||||
|
await this.maybeWriteNfo(contentItem, channel, finalPath, logPrefix);
|
||||||
|
|
||||||
// Emit download:complete event
|
// Emit download:complete event
|
||||||
this.eventBus?.emitDownload('download:complete', { contentItemId: contentItem.id });
|
this.eventBus?.emitDownload('download:complete', { contentItemId: contentItem.id });
|
||||||
|
|
||||||
|
|
@ -457,6 +462,29 @@ export class DownloadService {
|
||||||
if (url.includes('soundcloud.com')) return 'soundcloud';
|
if (url.includes('soundcloud.com')) return 'soundcloud';
|
||||||
return 'generic';
|
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<void> {
|
||||||
|
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 ──
|
// ── Helpers ──
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue