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:
parent
e0b6424932
commit
7f6f3dcccf
5 changed files with 410 additions and 1 deletions
267
src/__tests__/nfo-generator.test.ts
Normal file
267
src/__tests__/nfo-generator.test.ts
Normal 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 & Jerry <"Special"></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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
138
src/services/nfo-generator.ts
Normal file
138
src/services/nfo-generator.ts
Normal 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, '&')
|
||||
.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)}</${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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue