diff --git a/Dockerfile b/Dockerfile index 7b4e487..b5a1fea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,20 @@ FROM node:20-slim AS builder +RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY package.json tsconfig.json ./ RUN npm install +# Build the git dependency (filename-tools) +RUN cd node_modules/@metazla/filename-tools && npm install && npm run build COPY src/ ./src/ RUN npm run build FROM node:20-slim +RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY package.json ./ RUN npm install --omit=dev +# Build the git dependency in production too +RUN cd node_modules/@metazla/filename-tools && npm install && npm run build COPY --from=builder /app/dist ./dist EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ diff --git a/package.json b/package.json index a3c8fe0..c260f43 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,11 @@ "type": "module", "main": "dist/index.js", "scripts": { "build": "tsc", "start": "node dist/index.js" }, - "dependencies": { "fastify": "^5.0.0", "xml2js": "^0.6.2", "franc-min": "^6.2.0" }, + "dependencies": { + "fastify": "^5.0.0", + "xml2js": "^0.6.2", + "franc-min": "^6.2.0", + "@metazla/filename-tools": "github:worph/filename-tool" + }, "devDependencies": { "@types/node": "^20.0.0", "@types/xml2js": "^0.4.14", "typescript": "^5.3.0" } } diff --git a/src/index.ts b/src/index.ts index a1db0dc..dc4778d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,26 @@ +/** + * MetaMesh Plugin: jellyfin-nfo + * + * ============================================================================ + * PLUGIN MOUNT ARCHITECTURE - DO NOT MODIFY WITHOUT AUTHORIZATION + * ============================================================================ + * + * Each plugin container has exactly 3 mounts (2 for plugins without output): + * + * 1. /files (READ-ONLY) - Shared media files, read access only + * 2. /cache (READ-WRITE) - Plugin-specific cache folder + * 3. /files/plugin/ (READ-WRITE) - Plugin output folder (if needed) + * + * This plugin (jellyfin-nfo) only requires mounts 1 and 2: + * - /files (RO) - to read NFO files alongside media files + * - /cache (RW) - to cache parsed NFO results + * + * SECURITY: Plugins must NEVER write to /files directly. + * All write operations go to /cache or /files/plugin/ only. + * + * ============================================================================ + */ + import Fastify from 'fastify'; import type { HealthResponse, ProcessRequest, ProcessResponse, CallbackPayload, ConfigureRequest, ConfigureResponse } from './types.js'; import { manifest, process as processFile } from './plugin.js'; diff --git a/src/meta-core-client.ts b/src/meta-core-client.ts index edbe17c..6bdbad8 100644 --- a/src/meta-core-client.ts +++ b/src/meta-core-client.ts @@ -70,4 +70,24 @@ export class MetaCoreClient { const data = await response.json() as { metadata?: Record }; return data.metadata ?? {}; } + + /** + * Compute the CID (Content Identifier) for a file + * Uses the meta-core /file/cid API endpoint + */ + async computeFileCID(filePath: string): Promise { + try { + const response = await this.safeFetch(`${this.baseUrl}/file/cid`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: filePath }), + }); + if (!response || !response.ok) return null; + const data = await response.json() as { cid?: string }; + return data.cid ?? null; + } catch (error) { + console.debug(`[MetaCoreClient] Failed to compute CID for ${filePath}:`, error); + return null; + } + } } diff --git a/src/plugin.ts b/src/plugin.ts index c37c4ff..26744ab 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,15 +1,26 @@ /** * Jellyfin NFO Plugin - * Parses Jellyfin/Kodi NFO files for movies and TV shows + * + * Parses Jellyfin/Kodi NFO files for movies and TV shows. + * + * Matches old JellyFinNfoProcessor output: + * - videoType, originalTitle, titles/{lang} + * - episode, season, movieYear + * - rating, anidbid, imdbid, tmdbid, mpaa, criticrating, releasedate + * - art/fanart, art/poster + * - languages (add), genres (add), studio (add), plot/{lang}, tags (add) */ import { readFile, access } from 'fs/promises'; import { dirname, join } from 'path'; import { Parser } from 'xml2js'; import { franc } from 'franc-min'; +import { anyTo_iso_639_3 } from '@metazla/filename-tools'; import type { PluginManifest, ProcessRequest, CallbackPayload } from './types.js'; import { MetaCoreClient } from './meta-core-client.js'; +const englishIsoCode = 'eng'; + export const manifest: PluginManifest = { id: 'jellyfin-nfo', name: 'Jellyfin NFO Parser', @@ -30,6 +41,9 @@ export const manifest: PluginManifest = { rating: { label: 'Rating', type: 'string' }, imdbid: { label: 'IMDB ID', type: 'string' }, tmdbid: { label: 'TMDB ID', type: 'string' }, + anidbid: { label: 'AniDB ID', type: 'string' }, + 'art/poster': { label: 'Poster CID', type: 'cid' }, + 'art/fanart': { label: 'Fanart CID', type: 'cid' }, }, config: {}, }; @@ -66,6 +80,7 @@ export async function process( try { const { cid, filePath, existingMeta } = request; + // Only process video files if (existingMeta?.fileType !== 'video') { await sendCallback({ taskId: request.taskId, @@ -76,43 +91,34 @@ export async function process( return; } - const metadata: Record = {}; - const sets: Array<{ key: string; value: string }> = []; - - // Try file-specific NFO + // Try file-specific NFO first const nfoPath = filePath.replace(/\.[^.]+$/, '.nfo'); if (await fileExists(nfoPath)) { try { - const parsed = await parseNfoFile(nfoPath); - const root = parsed.episodedetails || parsed.movie || parsed.tvshow; - if (root) { - extractNfoData(root, metadata, sets, parsed.movie ? 'movie' : 'tvshow'); - } - } catch (e) { - console.log(`[jellyfin-nfo] Error parsing ${nfoPath}`); + const nfoContent = await readFile(nfoPath, 'utf8'); + const parser = new Parser({ explicitArray: false, mergeAttrs: true }); + const parsed = await parser.parseStringPromise(nfoContent); + await extractNfoData(metaCore, cid, parsed, filePath); + } catch (error) { + console.debug(`[jellyfin-nfo] Error parsing file NFO ${nfoPath}: ${error}`); } } - // Try tvshow.nfo in directory - const tvshowNfo = join(dirname(filePath), 'tvshow.nfo'); - if (await fileExists(tvshowNfo)) { + // Then try folder-level NFO (tvshow.nfo) + const dirPath = dirname(filePath); + const tvShowNfoPath = join(dirPath, 'tvshow.nfo'); + if (await fileExists(tvShowNfoPath)) { try { - const parsed = await parseNfoFile(tvshowNfo); - if (parsed.tvshow) { - extractNfoData(parsed.tvshow, metadata, sets, 'tvshow'); - } - } catch (e) { - console.log(`[jellyfin-nfo] Error parsing ${tvshowNfo}`); + const nfoContent = await readFile(tvShowNfoPath, 'utf8'); + const parser = new Parser({ explicitArray: false, mergeAttrs: true }); + const parsed = await parser.parseStringPromise(nfoContent); + await extractNfoData(metaCore, cid, parsed, filePath); + } catch (error) { + console.debug(`[jellyfin-nfo] Error parsing folder NFO ${tvShowNfoPath}: ${error}`); } } - if (Object.keys(metadata).length > 0) { - await metaCore.mergeMetadata(cid, metadata); - } - - for (const { key, value } of sets) { - await metaCore.addToSet(cid, key, value); - } + console.log(`[jellyfin-nfo] Processed NFO for ${filePath}`); await sendCallback({ taskId: request.taskId, @@ -129,41 +135,133 @@ export async function process( } } -function extractNfoData( - root: any, - metadata: Record, - sets: Array<{ key: string; value: string }>, - videoType: string -): void { - metadata.videoType = videoType; +/** + * Extract data from parsed NFO + */ +async function extractNfoData( + metaCore: MetaCoreClient, + cid: string, + parsed: any, + filePath: string +): Promise { + // Find the root element (episodedetails, movie, or tvshow) + const root = parsed.episodedetails || parsed.movie || parsed.tvshow; + if (!root) { + return; + } - if (root.originaltitle) metadata.originalTitle = root.originaltitle; - if (root.title && !metadata.originalTitle) metadata.originalTitle = root.title; - if (root.episode) metadata.episode = String(root.episode); - if (root.season) metadata.season = String(root.season); - if (root.year) metadata.movieYear = String(root.year); - if (root.rating) metadata.rating = String(root.rating); - if (root.imdbid) metadata.imdbid = root.imdbid; - if (root.tmdbid) metadata.tmdbid = String(root.tmdbid); - if (root.anidbid) metadata.anidbid = String(root.anidbid); - if (root.mpaa) metadata.mpaa = root.mpaa; - if (root.releasedate) metadata.releasedate = root.releasedate; + // Determine video type + const videoType = parsed.movie ? 'movie' : 'tvshow'; + await metaCore.setProperty(cid, 'videoType', videoType); - // Plot with language detection + // Basic metadata + if (root.originaltitle) { + await metaCore.setProperty(cid, 'originalTitle', root.originaltitle); + const lang = anyTo_iso_639_3(root.language) || englishIsoCode; + await metaCore.setProperty(cid, `titles/${lang}`, root.originaltitle); + await metaCore.addToSet(cid, 'languages', lang); + } + + if (root.title) { + const lang = anyTo_iso_639_3(root.language) || englishIsoCode; + // Only set if not already set via originaltitle + await metaCore.setProperty(cid, `titles/${lang}`, root.title); + await metaCore.addToSet(cid, 'languages', lang); + } + + // Episode/Season info + try { + if (root.episode && parseInt(root.episode) > 0) { + await metaCore.setProperty(cid, 'episode', String(root.episode)); + } + } catch (e) { + console.debug(`[jellyfin-nfo] Error parsing episode: ${e}`); + } + + try { + if (root.season && parseInt(root.season) > 0) { + await metaCore.setProperty(cid, 'season', String(root.season)); + } + } catch (e) { + console.debug(`[jellyfin-nfo] Error parsing season: ${e}`); + } + + // Year (movieYear) + if (root.year) await metaCore.setProperty(cid, 'movieYear', String(root.year)); + if (root.rating) await metaCore.setProperty(cid, 'rating', String(root.rating)); + if (root.anidbid) await metaCore.setProperty(cid, 'anidbid', String(root.anidbid)); + if (root.imdbid) await metaCore.setProperty(cid, 'imdbid', String(root.imdbid)); + if (root.tmdbid) await metaCore.setProperty(cid, 'tmdbid', String(root.tmdbid)); + if (root.mpaa) await metaCore.setProperty(cid, 'mpaa', String(root.mpaa)); + if (root.criticrating) await metaCore.setProperty(cid, 'criticrating', String(root.criticrating)); + if (root.releasedate) await metaCore.setProperty(cid, 'releasedate', String(root.releasedate)); + + // Art (poster and fanart) - compute CIDs using meta-core API + if (root.art?.poster) { + try { + const posterCid = await metaCore.computeFileCID(root.art.poster); + if (posterCid) await metaCore.setProperty(cid, 'art/poster', posterCid); + } catch (e) { + console.debug(`[jellyfin-nfo] Error getting poster CID: ${e}`); + } + } + if (root.art?.fanart) { + try { + const fanartCid = await metaCore.computeFileCID(root.art.fanart); + if (fanartCid) await metaCore.setProperty(cid, 'art/fanart', fanartCid); + } catch (e) { + console.debug(`[jellyfin-nfo] Error getting fanart CID: ${e}`); + } + } + + // Languages (add from root.language) + if (root.language) { + await metaCore.addToSet(cid, 'languages', String(root.language)); + } + + // Genres (add) + try { + const genres = normalizeArray(root.genre); + for (const genre of genres) { + await metaCore.addToSet(cid, 'genres', String(genre)); + } + } catch (e) { + console.debug(`[jellyfin-nfo] Error parsing genres: ${e}`); + } + + // Studios (add) + try { + const studios = normalizeArray(root.studio); + for (const studio of studios) { + await metaCore.addToSet(cid, 'studio', String(studio)); + } + } catch (e) { + console.debug(`[jellyfin-nfo] Error parsing studios: ${e}`); + } + + // Tags (add) + try { + const tags = normalizeArray(root.tag || root.tags); + for (const tag of tags) { + await metaCore.addToSet(cid, 'tags', String(tag)); + } + } catch (e) { + console.debug(`[jellyfin-nfo] Error parsing tags: ${e}`); + } + + // Plot if (root.plot) { - const lang = franc(root.plot); - const langKey = lang && lang !== 'und' ? lang : 'eng'; - metadata[`plot/${langKey}`] = root.plot; + try { + const plotLang = franc(root.plot); + if (plotLang && plotLang !== 'und') { + await metaCore.setProperty(cid, `plot/${plotLang}`, root.plot); + } else { + await metaCore.setProperty(cid, `plot/${englishIsoCode}`, root.plot); + } + } catch (e) { + await metaCore.setProperty(cid, `plot/${englishIsoCode}`, root.plot); + } } - // Sets - for (const genre of normalizeArray(root.genre)) { - sets.push({ key: 'genres', value: genre }); - } - for (const studio of normalizeArray(root.studio)) { - sets.push({ key: 'studio', value: studio }); - } - for (const tag of normalizeArray(root.tag || root.tags)) { - sets.push({ key: 'tags', value: tag }); - } + console.debug(`[jellyfin-nfo] Extracted NFO data for ${filePath}`); }