feat: Built NfoGenerator service producing Kodi-compatible NFO XML with…

- "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
This commit is contained in:
jlightner 2026-04-04 06:12:35 +00:00
parent e0b6424932
commit 7f6f3dcccf
5 changed files with 410 additions and 1 deletions

View file

@ -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> = {}): 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> = {}): 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('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>');
expect(nfo).toContain('<episodedetails>');
expect(nfo).toContain('</episodedetails>');
expect(nfo).toContain('<title>Test Video Title</title>');
expect(nfo).toContain('<aired>2025-06-15</aired>');
expect(nfo).toContain('<studio>TestChannel</studio>');
expect(nfo).toContain('<genre>YouTube</genre>');
expect(nfo).toContain('<mpaa>NR</mpaa>');
expect(nfo).toContain('<thumb>https://i.ytimg.com/vi/abc123/maxresdefault.jpg</thumb>');
expect(nfo).toContain('<uniqueid type="youtube" default="true">abc123</uniqueid>');
});
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('<mpaa>TV-MA</mpaa>');
});
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('<mpaa>TV-14</mpaa>');
});
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('<mpaa>NR</mpaa>');
});
it('handles null channel gracefully', () => {
const item = makeContentItem();
const nfo = generateNfo(item, null);
expect(nfo).toContain('<episodedetails>');
expect(nfo).toContain('<title>Test Video Title</title>');
expect(nfo).not.toContain('<studio>');
expect(nfo).toContain('<genre>Online Media</genre>');
expect(nfo).toContain('<mpaa>NR</mpaa>');
expect(nfo).toContain('<uniqueid type="generic"');
});
it('omits aired when publishedAt is null', () => {
const item = makeContentItem({ publishedAt: null });
const channel = makeChannel();
const nfo = generateNfo(item, channel);
expect(nfo).not.toContain('<aired>');
});
it('omits thumb when thumbnailUrl is null', () => {
const item = makeContentItem({ thumbnailUrl: null });
const channel = makeChannel();
const nfo = generateNfo(item, channel);
expect(nfo).not.toContain('<thumb>');
});
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('<title>Tom &amp; Jerry &lt;&quot;Special&quot;&gt;</title>');
});
it('uses Music genre for SoundCloud', () => {
const item = makeContentItem();
const channel = makeChannel({ platform: 'soundcloud' });
const nfo = generateNfo(item, channel);
expect(nfo).toContain('<genre>Music</genre>');
});
it('uses Online Media genre for generic platform', () => {
const item = makeContentItem();
const channel = makeChannel({ platform: 'generic' });
const nfo = generateNfo(item, channel);
expect(nfo).toContain('<genre>Online Media</genre>');
});
});
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 = '<episodedetails><title>Test</title></episodedetails>';
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 = '<episodedetails><title>Test</title></episodedetails>';
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');
});
});
});

View file

@ -140,6 +140,7 @@ function makeChannel(overrides: Partial<Channel> = {}): Channel {
subscriberCount: null,
includeKeywords: null,
excludeKeywords: null,
contentRating: null,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
lastCheckedAt: null,

View file

@ -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,
};

View file

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/** 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)}</${tag}>`;
}
/** Build an XML element with attributes. Omits if value is null/undefined/empty. */
function xmlElementWithAttr(
tag: string,
value: string | null | undefined,
attrs: Record<string, string>
): string {
if (value == null || value === '') return '';
const attrStr = Object.entries(attrs)
.map(([k, v]) => `${k}="${escapeXml(v)}"`)
.join(' ');
return ` <${tag} ${attrStr}>${escapeXml(value)}</${tag}>`;
}
// ── NFO Generation ──
/**
* Resolve the effective content rating for an item.
* Priority: item-level channel-level 'NR' (Not Rated).
*/
export function resolveContentRating(
contentItem: Pick<ContentItem, 'contentRating'>,
channel: Pick<Channel, 'contentRating'> | 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 `<episodedetails>` (not `<movie>`) 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 [
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
'<episodedetails>',
...elements,
'</episodedetails>',
'', // 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<string> {
const nfoPath = nfoPathForMedia(mediaFilePath);
await fs.mkdir(path.dirname(nfoPath), { recursive: true });
await fs.writeFile(nfoPath, nfoContent, 'utf-8');
return nfoPath;
}

View file

@ -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;