bugfix
This commit is contained in:
@@ -1,14 +1,20 @@
|
|||||||
FROM node:20-slim AS builder
|
FROM node:20-slim AS builder
|
||||||
|
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json tsconfig.json ./
|
COPY package.json tsconfig.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
# Build the git dependency (filename-tools)
|
||||||
|
RUN cd node_modules/@metazla/filename-tools && npm install && npm run build
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:20-slim
|
FROM node:20-slim
|
||||||
|
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
RUN npm install --omit=dev
|
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
|
COPY --from=builder /app/dist ./dist
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
|||||||
+6
-1
@@ -5,6 +5,11 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": { "build": "tsc", "start": "node 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" }
|
"devDependencies": { "@types/node": "^20.0.0", "@types/xml2js": "^0.4.14", "typescript": "^5.3.0" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/<id> (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/<id> only.
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import type { HealthResponse, ProcessRequest, ProcessResponse, CallbackPayload, ConfigureRequest, ConfigureResponse } from './types.js';
|
import type { HealthResponse, ProcessRequest, ProcessResponse, CallbackPayload, ConfigureRequest, ConfigureResponse } from './types.js';
|
||||||
import { manifest, process as processFile } from './plugin.js';
|
import { manifest, process as processFile } from './plugin.js';
|
||||||
|
|||||||
@@ -70,4 +70,24 @@ export class MetaCoreClient {
|
|||||||
const data = await response.json() as { metadata?: Record<string, string> };
|
const data = await response.json() as { metadata?: Record<string, string> };
|
||||||
return data.metadata ?? {};
|
return data.metadata ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the CID (Content Identifier) for a file
|
||||||
|
* Uses the meta-core /file/cid API endpoint
|
||||||
|
*/
|
||||||
|
async computeFileCID(filePath: string): Promise<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+158
-60
@@ -1,15 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* Jellyfin NFO Plugin
|
* 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 { readFile, access } from 'fs/promises';
|
||||||
import { dirname, join } from 'path';
|
import { dirname, join } from 'path';
|
||||||
import { Parser } from 'xml2js';
|
import { Parser } from 'xml2js';
|
||||||
import { franc } from 'franc-min';
|
import { franc } from 'franc-min';
|
||||||
|
import { anyTo_iso_639_3 } from '@metazla/filename-tools';
|
||||||
import type { PluginManifest, ProcessRequest, CallbackPayload } from './types.js';
|
import type { PluginManifest, ProcessRequest, CallbackPayload } from './types.js';
|
||||||
import { MetaCoreClient } from './meta-core-client.js';
|
import { MetaCoreClient } from './meta-core-client.js';
|
||||||
|
|
||||||
|
const englishIsoCode = 'eng';
|
||||||
|
|
||||||
export const manifest: PluginManifest = {
|
export const manifest: PluginManifest = {
|
||||||
id: 'jellyfin-nfo',
|
id: 'jellyfin-nfo',
|
||||||
name: 'Jellyfin NFO Parser',
|
name: 'Jellyfin NFO Parser',
|
||||||
@@ -30,6 +41,9 @@ export const manifest: PluginManifest = {
|
|||||||
rating: { label: 'Rating', type: 'string' },
|
rating: { label: 'Rating', type: 'string' },
|
||||||
imdbid: { label: 'IMDB ID', type: 'string' },
|
imdbid: { label: 'IMDB ID', type: 'string' },
|
||||||
tmdbid: { label: 'TMDB 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: {},
|
config: {},
|
||||||
};
|
};
|
||||||
@@ -66,6 +80,7 @@ export async function process(
|
|||||||
try {
|
try {
|
||||||
const { cid, filePath, existingMeta } = request;
|
const { cid, filePath, existingMeta } = request;
|
||||||
|
|
||||||
|
// Only process video files
|
||||||
if (existingMeta?.fileType !== 'video') {
|
if (existingMeta?.fileType !== 'video') {
|
||||||
await sendCallback({
|
await sendCallback({
|
||||||
taskId: request.taskId,
|
taskId: request.taskId,
|
||||||
@@ -76,43 +91,34 @@ export async function process(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata: Record<string, string> = {};
|
// Try file-specific NFO first
|
||||||
const sets: Array<{ key: string; value: string }> = [];
|
|
||||||
|
|
||||||
// Try file-specific NFO
|
|
||||||
const nfoPath = filePath.replace(/\.[^.]+$/, '.nfo');
|
const nfoPath = filePath.replace(/\.[^.]+$/, '.nfo');
|
||||||
if (await fileExists(nfoPath)) {
|
if (await fileExists(nfoPath)) {
|
||||||
try {
|
try {
|
||||||
const parsed = await parseNfoFile(nfoPath);
|
const nfoContent = await readFile(nfoPath, 'utf8');
|
||||||
const root = parsed.episodedetails || parsed.movie || parsed.tvshow;
|
const parser = new Parser({ explicitArray: false, mergeAttrs: true });
|
||||||
if (root) {
|
const parsed = await parser.parseStringPromise(nfoContent);
|
||||||
extractNfoData(root, metadata, sets, parsed.movie ? 'movie' : 'tvshow');
|
await extractNfoData(metaCore, cid, parsed, filePath);
|
||||||
}
|
} catch (error) {
|
||||||
} catch (e) {
|
console.debug(`[jellyfin-nfo] Error parsing file NFO ${nfoPath}: ${error}`);
|
||||||
console.log(`[jellyfin-nfo] Error parsing ${nfoPath}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try tvshow.nfo in directory
|
// Then try folder-level NFO (tvshow.nfo)
|
||||||
const tvshowNfo = join(dirname(filePath), 'tvshow.nfo');
|
const dirPath = dirname(filePath);
|
||||||
if (await fileExists(tvshowNfo)) {
|
const tvShowNfoPath = join(dirPath, 'tvshow.nfo');
|
||||||
|
if (await fileExists(tvShowNfoPath)) {
|
||||||
try {
|
try {
|
||||||
const parsed = await parseNfoFile(tvshowNfo);
|
const nfoContent = await readFile(tvShowNfoPath, 'utf8');
|
||||||
if (parsed.tvshow) {
|
const parser = new Parser({ explicitArray: false, mergeAttrs: true });
|
||||||
extractNfoData(parsed.tvshow, metadata, sets, 'tvshow');
|
const parsed = await parser.parseStringPromise(nfoContent);
|
||||||
}
|
await extractNfoData(metaCore, cid, parsed, filePath);
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
console.log(`[jellyfin-nfo] Error parsing ${tvshowNfo}`);
|
console.debug(`[jellyfin-nfo] Error parsing folder NFO ${tvShowNfoPath}: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(metadata).length > 0) {
|
console.log(`[jellyfin-nfo] Processed NFO for ${filePath}`);
|
||||||
await metaCore.mergeMetadata(cid, metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const { key, value } of sets) {
|
|
||||||
await metaCore.addToSet(cid, key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
await sendCallback({
|
await sendCallback({
|
||||||
taskId: request.taskId,
|
taskId: request.taskId,
|
||||||
@@ -129,41 +135,133 @@ export async function process(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractNfoData(
|
/**
|
||||||
root: any,
|
* Extract data from parsed NFO
|
||||||
metadata: Record<string, string>,
|
*/
|
||||||
sets: Array<{ key: string; value: string }>,
|
async function extractNfoData(
|
||||||
videoType: string
|
metaCore: MetaCoreClient,
|
||||||
): void {
|
cid: string,
|
||||||
metadata.videoType = videoType;
|
parsed: any,
|
||||||
|
filePath: string
|
||||||
|
): Promise<void> {
|
||||||
|
// 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;
|
// Determine video type
|
||||||
if (root.title && !metadata.originalTitle) metadata.originalTitle = root.title;
|
const videoType = parsed.movie ? 'movie' : 'tvshow';
|
||||||
if (root.episode) metadata.episode = String(root.episode);
|
await metaCore.setProperty(cid, 'videoType', videoType);
|
||||||
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;
|
|
||||||
|
|
||||||
// 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) {
|
if (root.plot) {
|
||||||
const lang = franc(root.plot);
|
try {
|
||||||
const langKey = lang && lang !== 'und' ? lang : 'eng';
|
const plotLang = franc(root.plot);
|
||||||
metadata[`plot/${langKey}`] = 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
|
console.debug(`[jellyfin-nfo] Extracted NFO data for ${filePath}`);
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user