From 7f6f3dcccfc6141d0e58458ac029f686e41f2bd8 Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 06:12:35 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Built=20NfoGenerator=20service=20produc?= =?UTF-8?q?ing=20Kodi-compatible=20NFO=20XML=20with=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/services/nfo-generator.ts" - "src/__tests__/nfo-generator.test.ts" - "src/types/index.ts" - "src/db/repositories/channel-repository.ts" - "src/__tests__/sources.test.ts" GSD-Task: S05/T02 --- src/__tests__/nfo-generator.test.ts | 267 ++++++++++++++++++++++ src/__tests__/sources.test.ts | 1 + src/db/repositories/channel-repository.ts | 4 +- src/services/nfo-generator.ts | 138 +++++++++++ src/types/index.ts | 1 + 5 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/nfo-generator.test.ts create mode 100644 src/services/nfo-generator.ts diff --git a/src/__tests__/nfo-generator.test.ts b/src/__tests__/nfo-generator.test.ts new file mode 100644 index 0000000..db393e5 --- /dev/null +++ b/src/__tests__/nfo-generator.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + generateNfo, + writeNfoFile, + nfoPathForMedia, + resolveContentRating, +} from '../services/nfo-generator'; +import type { ContentItem, Channel } from '../types/index'; + +// ── Test Fixtures ── + +function makeContentItem(overrides: Partial = {}): ContentItem { + return { + id: 1, + channelId: 1, + title: 'Test Video Title', + platformContentId: 'abc123', + url: 'https://youtube.com/watch?v=abc123', + contentType: 'video', + duration: 600, + filePath: '/media/youtube/TestChannel/Test Video Title.mp4', + fileSize: 50_000_000, + format: 'mp4', + qualityMetadata: null, + status: 'downloaded', + thumbnailUrl: 'https://i.ytimg.com/vi/abc123/maxresdefault.jpg', + publishedAt: '2025-06-15T12:00:00Z', + downloadedAt: '2025-06-16T08:30:00Z', + monitored: true, + contentRating: null, + createdAt: '2025-06-15T12:00:00Z', + updatedAt: '2025-06-16T08:30:00Z', + ...overrides, + }; +} + +function makeChannel(overrides: Partial = {}): Channel { + return { + id: 1, + name: 'TestChannel', + platform: 'youtube', + platformId: 'UC1234', + url: 'https://youtube.com/@TestChannel', + monitoringEnabled: true, + checkInterval: 60, + imageUrl: 'https://example.com/avatar.jpg', + metadata: null, + formatProfileId: null, + monitoringMode: 'all', + bannerUrl: null, + description: null, + subscriberCount: null, + contentRating: null, + includeKeywords: null, + excludeKeywords: null, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + lastCheckedAt: null, + lastCheckStatus: null, + ...overrides, + }; +} + +// ── File writing cleanup ── + +let tmpDir: string | undefined; + +afterEach(() => { + if (tmpDir && existsSync(tmpDir)) { + rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } +}); + +// ── Tests ── + +describe('NfoGenerator', () => { + describe('resolveContentRating', () => { + it('uses item-level rating when present', () => { + const result = resolveContentRating( + { contentRating: 'TV-MA' }, + { contentRating: 'TV-PG' } + ); + expect(result).toBe('TV-MA'); + }); + + it('falls back to channel rating when item rating is null', () => { + const result = resolveContentRating( + { contentRating: null }, + { contentRating: 'TV-PG' } + ); + expect(result).toBe('TV-PG'); + }); + + it('falls back to NR when both are null', () => { + const result = resolveContentRating( + { contentRating: null }, + { contentRating: null } + ); + expect(result).toBe('NR'); + }); + + it('falls back to NR when channel is null', () => { + const result = resolveContentRating({ contentRating: null }, null); + expect(result).toBe('NR'); + }); + }); + + describe('generateNfo', () => { + it('produces valid Kodi XML with all fields populated', () => { + const item = makeContentItem(); + const channel = makeChannel(); + const nfo = generateNfo(item, channel); + + expect(nfo).toContain(''); + expect(nfo).toContain(''); + expect(nfo).toContain(''); + expect(nfo).toContain('Test Video Title'); + expect(nfo).toContain('2025-06-15'); + expect(nfo).toContain('TestChannel'); + expect(nfo).toContain('YouTube'); + expect(nfo).toContain('NR'); + expect(nfo).toContain('https://i.ytimg.com/vi/abc123/maxresdefault.jpg'); + expect(nfo).toContain('abc123'); + }); + + it('uses item contentRating over channel rating', () => { + const item = makeContentItem({ contentRating: 'TV-MA' }); + const channel = makeChannel({ contentRating: 'TV-PG' }); + const nfo = generateNfo(item, channel); + + expect(nfo).toContain('TV-MA'); + }); + + it('uses channel contentRating when item has none', () => { + const item = makeContentItem({ contentRating: null }); + const channel = makeChannel({ contentRating: 'TV-14' }); + const nfo = generateNfo(item, channel); + + expect(nfo).toContain('TV-14'); + }); + + it('defaults to NR when neither has rating', () => { + const item = makeContentItem({ contentRating: null }); + const channel = makeChannel({ contentRating: null }); + const nfo = generateNfo(item, channel); + + expect(nfo).toContain('NR'); + }); + + it('handles null channel gracefully', () => { + const item = makeContentItem(); + const nfo = generateNfo(item, null); + + expect(nfo).toContain(''); + expect(nfo).toContain('Test Video Title'); + expect(nfo).not.toContain(''); + expect(nfo).toContain('Online Media'); + expect(nfo).toContain('NR'); + expect(nfo).toContain(' { + const item = makeContentItem({ publishedAt: null }); + const channel = makeChannel(); + const nfo = generateNfo(item, channel); + + expect(nfo).not.toContain(''); + }); + + it('omits thumb when thumbnailUrl is null', () => { + const item = makeContentItem({ thumbnailUrl: null }); + const channel = makeChannel(); + const nfo = generateNfo(item, channel); + + expect(nfo).not.toContain(''); + }); + + it('escapes XML special characters in title', () => { + const item = makeContentItem({ title: 'Tom & Jerry <"Special">' }); + const channel = makeChannel(); + const nfo = generateNfo(item, channel); + + expect(nfo).toContain('Tom & Jerry <"Special">'); + }); + + it('uses Music genre for SoundCloud', () => { + const item = makeContentItem(); + const channel = makeChannel({ platform: 'soundcloud' }); + const nfo = generateNfo(item, channel); + + expect(nfo).toContain('Music'); + }); + + it('uses Online Media genre for generic platform', () => { + const item = makeContentItem(); + const channel = makeChannel({ platform: 'generic' }); + const nfo = generateNfo(item, channel); + + expect(nfo).toContain('Online Media'); + }); + }); + + describe('nfoPathForMedia', () => { + it('replaces .mp4 with .nfo', () => { + expect(nfoPathForMedia('/media/youtube/chan/video.mp4')).toBe( + '/media/youtube/chan/video.nfo' + ); + }); + + it('replaces .webm with .nfo', () => { + expect(nfoPathForMedia('/media/youtube/chan/video.webm')).toBe( + '/media/youtube/chan/video.nfo' + ); + }); + + it('replaces .opus with .nfo', () => { + expect(nfoPathForMedia('/media/soundcloud/artist/track.opus')).toBe( + '/media/soundcloud/artist/track.nfo' + ); + }); + + it('handles files with dots in the name', () => { + expect(nfoPathForMedia('/media/youtube/chan/video.v2.final.mp4')).toBe( + '/media/youtube/chan/video.v2.final.nfo' + ); + }); + }); + + describe('writeNfoFile', () => { + it('writes .nfo file alongside media file', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-nfo-test-')); + const mediaPath = join(tmpDir, 'youtube', 'TestChannel', 'video.mp4'); + const nfoContent = 'Test'; + + const writtenPath = await writeNfoFile(nfoContent, mediaPath); + + expect(writtenPath).toBe(join(tmpDir, 'youtube', 'TestChannel', 'video.nfo')); + expect(existsSync(writtenPath)).toBe(true); + expect(readFileSync(writtenPath, 'utf-8')).toBe(nfoContent); + }); + + it('creates parent directories if needed', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-nfo-test-')); + const mediaPath = join(tmpDir, 'deep', 'nested', 'dir', 'video.mp4'); + const nfoContent = 'Test'; + + const writtenPath = await writeNfoFile(nfoContent, mediaPath); + + expect(existsSync(writtenPath)).toBe(true); + }); + + it('overwrites existing .nfo file', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-nfo-test-')); + const mediaPath = join(tmpDir, 'video.mp4'); + + await writeNfoFile('old content', mediaPath); + await writeNfoFile('new content', mediaPath); + + const nfoPath = join(tmpDir, 'video.nfo'); + expect(readFileSync(nfoPath, 'utf-8')).toBe('new content'); + }); + }); +}); diff --git a/src/__tests__/sources.test.ts b/src/__tests__/sources.test.ts index 437adf1..2207067 100644 --- a/src/__tests__/sources.test.ts +++ b/src/__tests__/sources.test.ts @@ -140,6 +140,7 @@ function makeChannel(overrides: Partial = {}): Channel { subscriberCount: null, includeKeywords: null, excludeKeywords: null, + contentRating: null, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', lastCheckedAt: null, diff --git a/src/db/repositories/channel-repository.ts b/src/db/repositories/channel-repository.ts index a6ffa4d..3a163da 100644 --- a/src/db/repositories/channel-repository.ts +++ b/src/db/repositories/channel-repository.ts @@ -9,7 +9,7 @@ import type { Channel, Platform, MonitoringMode } from '../../types/index'; /** Fields needed to create a new channel (auto-generated fields excluded). */ export type CreateChannelData = Omit< Channel, - 'id' | 'createdAt' | 'updatedAt' | 'lastCheckedAt' | 'lastCheckStatus' | 'monitoringMode' | 'bannerUrl' | 'description' | 'subscriberCount' | 'includeKeywords' | 'excludeKeywords' + 'id' | 'createdAt' | 'updatedAt' | 'lastCheckedAt' | 'lastCheckStatus' | 'monitoringMode' | 'bannerUrl' | 'description' | 'subscriberCount' | 'includeKeywords' | 'excludeKeywords' | 'contentRating' > & { monitoringMode?: Channel['monitoringMode']; bannerUrl?: string | null; @@ -17,6 +17,7 @@ export type CreateChannelData = Omit< subscriberCount?: number | null; includeKeywords?: string | null; excludeKeywords?: string | null; + contentRating?: string | null; }; /** Fields that can be updated on an existing channel. */ @@ -204,6 +205,7 @@ function mapRow(row: typeof channels.$inferSelect): Channel { updatedAt: row.updatedAt, lastCheckedAt: row.lastCheckedAt, lastCheckStatus: row.lastCheckStatus as Channel['lastCheckStatus'], + contentRating: row.contentRating ?? null, includeKeywords: row.includeKeywords ?? null, excludeKeywords: row.excludeKeywords ?? null, }; diff --git a/src/services/nfo-generator.ts b/src/services/nfo-generator.ts new file mode 100644 index 0000000..ebde8d4 --- /dev/null +++ b/src/services/nfo-generator.ts @@ -0,0 +1,138 @@ +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import type { ContentItem, Channel } from '../types/index'; + +// ── XML Helpers ── + +/** Escape special XML characters in text content. */ +function escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** Build an XML element with escaped text content. Omits the element entirely if value is null/undefined/empty. */ +function xmlElement(tag: string, value: string | null | undefined): string { + if (value == null || value === '') return ''; + return ` <${tag}>${escapeXml(value)}`; +} + +/** Build an XML element with attributes. Omits if value is null/undefined/empty. */ +function xmlElementWithAttr( + tag: string, + value: string | null | undefined, + attrs: Record +): string { + if (value == null || value === '') return ''; + const attrStr = Object.entries(attrs) + .map(([k, v]) => `${k}="${escapeXml(v)}"`) + .join(' '); + return ` <${tag} ${attrStr}>${escapeXml(value)}`; +} + +// ── NFO Generation ── + +/** + * Resolve the effective content rating for an item. + * Priority: item-level → channel-level → 'NR' (Not Rated). + */ +export function resolveContentRating( + contentItem: Pick, + channel: Pick | null +): string { + return contentItem.contentRating ?? channel?.contentRating ?? 'NR'; +} + +/** + * Map a platform string to a genre label for NFO metadata. + * Kodi uses these to categorize content in the library UI. + */ +function platformToGenre(platform: string): string { + switch (platform) { + case 'youtube': + return 'YouTube'; + case 'soundcloud': + return 'Music'; + default: + return 'Online Media'; + } +} + +/** + * Generate a Kodi-compatible NFO XML string for a content item. + * + * Uses `` (not ``) since YouTube/SoundCloud content + * maps better to the episode model in Kodi — each video is one "episode" + * from a "show" (the channel). + * + * @param contentItem The content item to generate NFO for + * @param channel The parent channel (used for studio name and fallback rating) + * @returns A complete XML string ready to write to a .nfo file + */ +export function generateNfo( + contentItem: ContentItem, + channel: Channel | null +): string { + const rating = resolveContentRating(contentItem, channel); + const aired = contentItem.publishedAt + ? contentItem.publishedAt.slice(0, 10) // YYYY-MM-DD + : null; + const genre = channel ? platformToGenre(channel.platform) : 'Online Media'; + + const elements = [ + xmlElement('title', contentItem.title), + xmlElement('plot', contentItem.title), // Use title as plot fallback — no description field on ContentItem + xmlElement('aired', aired), + xmlElement('studio', channel?.name ?? null), + xmlElement('genre', genre), + xmlElement('mpaa', rating), + xmlElement('thumb', contentItem.thumbnailUrl), + xmlElementWithAttr('uniqueid', contentItem.platformContentId, { + type: channel?.platform ?? 'generic', + default: 'true', + }), + ].filter((line) => line !== ''); + + return [ + '', + '', + ...elements, + '', + '', // trailing newline + ].join('\n'); +} + +// ── File Writing ── + +/** + * Derive the .nfo file path from a media file path. + * Replaces the file extension with `.nfo`. + * + * @example nfoPathForMedia('/media/youtube/chan/video.mp4') → '/media/youtube/chan/video.nfo' + */ +export function nfoPathForMedia(mediaFilePath: string): string { + const ext = path.extname(mediaFilePath); + return mediaFilePath.slice(0, mediaFilePath.length - ext.length) + '.nfo'; +} + +/** + * Write an NFO file alongside a media file. + * Creates parent directories if they don't exist. + * Overwrites any existing .nfo file at the target path. + * + * @param nfoContent The XML string to write + * @param mediaFilePath The path to the media file (used to derive .nfo path) + * @returns The path where the .nfo file was written + */ +export async function writeNfoFile( + nfoContent: string, + mediaFilePath: string +): Promise { + const nfoPath = nfoPathForMedia(mediaFilePath); + await fs.mkdir(path.dirname(nfoPath), { recursive: true }); + await fs.writeFile(nfoPath, nfoContent, 'utf-8'); + return nfoPath; +} diff --git a/src/types/index.ts b/src/types/index.ts index 0aee476..b619bca 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -77,6 +77,7 @@ export interface Channel { bannerUrl: string | null; description: string | null; subscriberCount: number | null; + contentRating: string | null; includeKeywords: string | null; excludeKeywords: string | null; createdAt: string;