This commit is contained in:
Pierre
2026-01-10 19:15:35 +01:00
parent 2a740d2488
commit b96024ed1e
4 changed files with 1221 additions and 82 deletions
+54
View File
@@ -0,0 +1,54 @@
/**
* File-based cache utility for plugin data
* Matches the old ctx.cache API: readJson, writeJson
*/
import { readFile, writeFile, mkdir } from 'fs/promises';
import { existsSync } from 'fs';
import path from 'path';
const CACHE_DIR = process.env.CACHE_DIR || '/cache';
/**
* Ensure cache directory exists
*/
async function ensureCacheDir(): Promise<void> {
if (!existsSync(CACHE_DIR)) {
await mkdir(CACHE_DIR, { recursive: true });
}
}
/**
* Read JSON from cache
* @param filename - Cache filename (e.g., "abc123_tmdb.json")
* @returns Parsed JSON object or null if not found
*/
export async function readJson<T = unknown>(filename: string): Promise<T | null> {
try {
const filePath = path.join(CACHE_DIR, filename);
if (!existsSync(filePath)) {
return null;
}
const content = await readFile(filePath, 'utf-8');
return JSON.parse(content) as T;
} catch (error) {
console.debug(`[cache] Failed to read ${filename}:`, error);
return null;
}
}
/**
* Write JSON to cache
* @param filename - Cache filename (e.g., "abc123_tmdb.json")
* @param data - Data to cache
*/
export async function writeJson(filename: string, data: unknown): Promise<void> {
try {
await ensureCacheDir();
const filePath = path.join(CACHE_DIR, filename);
await writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
console.debug(`[cache] Wrote ${filename}`);
} catch (error) {
console.warn(`[cache] Failed to write ${filename}:`, error);
}
}
+31
View File
@@ -1,3 +1,29 @@
/**
* MetaMesh Plugin: TMDB
*
* ============================================================================
* PLUGIN MOUNT ARCHITECTURE - DO NOT MODIFY WITHOUT AUTHORIZATION
* ============================================================================
*
* Each plugin container has exactly 3 mounts:
*
* 1. /files (READ-ONLY) - Shared media files, read access only
* 2. /cache (READ-WRITE) - Plugin-specific cache folder
* 3. /files/plugin/<name> (READ-WRITE for this plugin, READ-ONLY for others)
* - Plugin output folder for generated files
*
* SECURITY: Plugins must NEVER write to /files directly.
* - Use /cache for temporary/cache data
* - Use /files/plugin/<name> for output files (e.g., posters, generated assets)
*
* This architecture ensures:
* - Plugins cannot corrupt user's media files
* - Plugins can share generated assets with other plugins (read-only)
* - Each plugin has isolated cache storage
*
* ============================================================================
*/
import Fastify from 'fastify';
import type { HealthResponse, ProcessRequest, ProcessResponse, CallbackPayload, ConfigureRequest, ConfigureResponse } from './types.js';
import { manifest, process as processFile, configure } from './plugin.js';
@@ -16,6 +42,11 @@ app.post<{ Body: ConfigureRequest }>('/configure', async (request): Promise<Conf
return { status: 'error', error: error instanceof Error ? error.message : String(error) };
}
});
app.post('/recompute', async () => {
configure({ forceRecompute: true });
console.log(`[${manifest.id}] Recompute mode enabled`);
return { status: 'ok', message: 'Recompute mode enabled' };
});
app.post<{ Body: ProcessRequest }>('/process', async (request, reply) => {
const { taskId, cid, filePath, callbackUrl, metaCoreUrl } = request.body;
if (!taskId || !cid || !filePath || !callbackUrl || !metaCoreUrl) {
+209 -82
View File
@@ -1,20 +1,56 @@
/**
* TMDB Plugin
* Fetches metadata from The Movie Database API
* Downloads poster and backdrop images to /files/poster/
*
* Fetches metadata from The Movie Database (TMDB) API.
* Supports both v3 API keys and v4 Bearer tokens.
* Downloads poster and backdrop images to /files/plugin/tmdb/
*
* ============================================================================
* PLUGIN MOUNT ARCHITECTURE - DO NOT MODIFY WITHOUT AUTHORIZATION
* ============================================================================
*
* Each plugin container has exactly 3 mounts:
*
* 1. /files (READ-ONLY) - Shared media files, read access only
* 2. /cache (READ-WRITE) - Plugin-specific cache folder
* 3. /files/plugin/tmdb (READ-WRITE) - Plugin output folder for posters/images
*
* SECURITY: Plugins must NEVER write to /files directly.
* - Use /cache for temporary/cache data (e.g., TMDB API responses)
* - Use /files/plugin/tmdb for output files (e.g., posters, backdrops)
*
* ============================================================================
*
* Matches old TMDBProcessor output:
* - tmdbid, imdbid
* - originalTitle, movieYear, releasedate
* - plot/eng, rating
* - genres (add), studio (add), tags (add)
* - poster, backdrop (CID hashes of downloaded images)
*/
import axios from 'axios';
import * as fs from 'fs/promises';
import { createWriteStream, existsSync, mkdirSync } from 'fs';
import * as path from 'path';
import { pipeline } from 'stream/promises';
import type { PluginManifest, ProcessRequest, CallbackPayload } from './types.js';
import { MetaCoreClient } from './meta-core-client.js';
import { readJson, writeJson } from './cache.js';
const IMAGE_BASE_URL = 'https://image.tmdb.org/t/p/original';
/**
* ============================================================================
* MOUNT PATHS - Enforced by plugin architecture
* ============================================================================
* FILES_PATH (/files) - READ-ONLY access to media files
* PLUGIN_OUTPUT_PATH (/files/plugin/tmdb) - READ-WRITE for plugin-generated files
* CACHE_PATH (/cache) - READ-WRITE for plugin cache (handled by cache.ts)
* ============================================================================
*/
// Use globalThis.process to avoid conflict with the exported process function
const FILES_PATH = (globalThis as any).process?.env?.FILES_PATH || '/files';
const PLUGIN_OUTPUT_PATH = (globalThis as any).process?.env?.PLUGIN_OUTPUT_PATH || '/files/plugin/tmdb';
export const manifest: PluginManifest = {
id: 'tmdb',
@@ -48,9 +84,17 @@ export const manifest: PluginManifest = {
label: 'Metadata Language',
default: 'en-US',
},
forceRecompute: {
type: 'boolean',
label: 'Force Recompute',
default: false,
},
},
};
// Track forceRecompute config
let forceRecompute = false;
const BASE_URL = 'https://api.themoviedb.org/3';
let apiKey: string | null = null;
@@ -70,6 +114,10 @@ export function configure(config: Record<string, unknown>): void {
metadataLanguage = lang.includes('-') ? lang : `${lang}-${lang.toUpperCase()}`;
if (metadataLanguage === 'en-en') metadataLanguage = 'en-US';
console.log(`[tmdb] Language set to: ${metadataLanguage}`);
forceRecompute = config.forceRecompute === true;
if (forceRecompute) {
console.log(`[tmdb] Force recompute enabled`);
}
}
function getAxiosConfig(params: Record<string, string> = {}) {
@@ -137,6 +185,9 @@ function sanitizeFilename(name: string): string {
/**
* Download an image from URL to local path
*
* IMPORTANT: Images are saved to PLUGIN_OUTPUT_PATH (/files/plugin/tmdb)
* NOT to /files directly. See mount architecture comments at top of file.
*/
async function downloadImage(imageUrl: string, localPath: string): Promise<boolean> {
try {
@@ -167,6 +218,11 @@ async function downloadImage(imageUrl: string, localPath: string): Promise<boole
/**
* Download and hash an image, returning its CID and path
*
* IMPORTANT: Images are saved to PLUGIN_OUTPUT_PATH (/files/plugin/tmdb)
* This is the plugin's dedicated output folder with READ-WRITE access.
* Other plugins can read these files but cannot write to this location.
*
* @returns {Promise<{cid: string, path: string} | null>}
*/
async function downloadAndHashImage(
@@ -190,20 +246,22 @@ async function downloadAndHashImage(
const yearStr = year ? ` (${year})` : '';
const filename = `${safeName}${yearStr}[tmdb${tmdbId}]_${imageType}${ext}`;
const posterDir = path.join(FILES_PATH, 'poster');
const localPath = path.join(posterDir, filename);
// IMPORTANT: Write to PLUGIN_OUTPUT_PATH, not FILES_PATH
// This respects the mount architecture: /files is READ-ONLY
const localPath = path.join(PLUGIN_OUTPUT_PATH, filename);
// Relative path for storage (relative to filesPath)
const relativePath = `poster/${filename}`;
// Relative path for storage (relative to /files for meta-core compatibility)
// Path format: plugin/tmdb/<filename>
const relativePath = `plugin/tmdb/${filename}`;
// Check if file already exists (skip download)
if (existsSync(localPath)) {
console.log(`[tmdb] Image already exists: ${localPath}`);
} else {
// Ensure poster directory exists
if (!existsSync(posterDir)) {
mkdirSync(posterDir, { recursive: true });
console.log(`[tmdb] Created poster directory: ${posterDir}`);
// Ensure plugin output directory exists
if (!existsSync(PLUGIN_OUTPUT_PATH)) {
mkdirSync(PLUGIN_OUTPUT_PATH, { recursive: true });
console.log(`[tmdb] Created plugin output directory: ${PLUGIN_OUTPUT_PATH}`);
}
// Download the image
@@ -259,8 +317,8 @@ export async function process(
return;
}
// Skip if already has TMDB data
if (existingMeta?.tmdbid) {
// Skip if already has TMDB data (unless forceRecompute)
if (existingMeta?.tmdbid && !forceRecompute) {
await sendCallback({
taskId: request.taskId,
status: 'skipped',
@@ -270,6 +328,28 @@ export async function process(
return;
}
if (forceRecompute) {
console.log(`[tmdb] Force recompute enabled for ${request.filePath}`);
}
// Get midhash for caching
const midhash = existingMeta?.['cid_midhash256'];
// Check cache (skip if forceRecompute)
if (midhash && !forceRecompute) {
const cachedData = await readJson<any>(`${midhash}_tmdb.json`);
if (cachedData) {
console.log(`[tmdb] Using cached TMDB data for ${request.filePath}`);
await applyTmdbData(metaCore, cid, cachedData);
await sendCallback({
taskId: request.taskId,
status: 'completed',
duration: Date.now() - startTime,
});
return;
}
}
let tmdbData = null;
// Try by IMDB ID first
@@ -296,78 +376,16 @@ export async function process(
}
if (tmdbData) {
const metadata: Record<string, string> = {};
await applyTmdbData(metaCore, cid, tmdbData);
const tmdbId = tmdbData.id ? String(tmdbData.id) : '';
if (tmdbId) metadata.tmdbid = tmdbId;
if (tmdbData.imdb_id) metadata.imdbid = tmdbData.imdb_id;
const originalTitle = tmdbData.original_title || tmdbData.original_name;
const localizedTitle = tmdbData.title || tmdbData.name;
if (localizedTitle) metadata.title = localizedTitle;
if (originalTitle) metadata.originalTitle = originalTitle;
const releaseDate = tmdbData.release_date || tmdbData.first_air_date;
let year: string | undefined;
if (releaseDate) {
metadata.releasedate = releaseDate;
year = releaseDate.split('-')[0];
if (year) {
metadata.movieYear = year;
}
// Cache TMDB data for future use
if (midhash) {
await writeJson(`${midhash}_tmdb.json`, tmdbData);
}
if (tmdbData.overview) metadata['plot/eng'] = tmdbData.overview;
if (tmdbData.vote_average) metadata.rating = String(tmdbData.vote_average);
// Download and hash poster/backdrop images
const displayTitle = localizedTitle || originalTitle || 'Unknown';
if (tmdbData.poster_path && tmdbId) {
const posterResult = await downloadAndHashImage(
tmdbData.poster_path,
'poster',
displayTitle,
year,
tmdbId,
metaCore
);
if (posterResult) {
metadata.poster = posterResult.cid;
metadata.posterPath = posterResult.path;
console.log(`[tmdb] Set poster CID: ${posterResult.cid}`);
}
}
if (tmdbData.backdrop_path && tmdbId) {
const backdropResult = await downloadAndHashImage(
tmdbData.backdrop_path,
'backdrop',
displayTitle,
year,
tmdbId,
metaCore
);
if (backdropResult) {
metadata.backdrop = backdropResult.cid;
metadata.backdropPath = backdropResult.path;
console.log(`[tmdb] Set backdrop CID: ${backdropResult.cid}`);
}
}
await metaCore.mergeMetadata(cid, metadata);
// Add genres
for (const genre of tmdbData.genres || []) {
await metaCore.addToSet(cid, 'genres', genre.name);
}
// Add studios
for (const company of tmdbData.production_companies || []) {
await metaCore.addToSet(cid, 'studio', company.name);
}
await metaCore.addToSet(cid, 'tags', 'tmdb-verified');
console.log(`[tmdb] Fetched TMDB data for ${request.filePath}`);
} else {
console.log(`[tmdb] No TMDB match found for ${request.filePath}`);
}
await sendCallback({
@@ -384,3 +402,112 @@ export async function process(
});
}
}
/**
* Apply TMDB data to KV store using same keys as old TMDBProcessor
*/
async function applyTmdbData(
metaCore: MetaCoreClient,
cid: string,
data: any
): Promise<void> {
const metadata: Record<string, string> = {};
// Basic IDs
const tmdbId = data.id ? String(data.id) : '';
if (tmdbId) metadata.tmdbid = tmdbId;
if (data.imdb_id) metadata.imdbid = data.imdb_id;
// Title - store both original and localized
const originalTitle = data.original_title || data.original_name;
const localizedTitle = data.title || data.name;
const displayTitle = localizedTitle || originalTitle || 'Unknown';
// Store localized title as the main title (for display in Stremio)
if (localizedTitle && localizedTitle !== originalTitle) {
metadata.title = localizedTitle;
}
// Store original title separately
if (originalTitle) {
metadata.originalTitle = originalTitle;
}
// Dates
const releaseDate = data.release_date || data.first_air_date;
let year: string | undefined;
if (releaseDate) {
metadata.releasedate = releaseDate;
year = releaseDate.split('-')[0];
if (year) {
metadata.movieYear = year;
}
}
// Plot (in configured language)
if (data.overview) {
// Extract language code from metadataLanguage (e.g., 'en-US' -> 'eng')
const langCode = metadataLanguage.split('-')[0];
const langKey = langCode === 'en' ? 'eng' : langCode;
metadata[`plot/${langKey}`] = data.overview;
// Also set as primary plot for compatibility
metadata['plot/eng'] = data.overview;
}
// Rating
if (data.vote_average) {
metadata.rating = String(data.vote_average);
}
await metaCore.mergeMetadata(cid, metadata);
// Genres (add)
if (data.genres && Array.isArray(data.genres)) {
for (const genre of data.genres) {
await metaCore.addToSet(cid, 'genres', genre.name);
}
}
// Production companies as studios (add)
if (data.production_companies && Array.isArray(data.production_companies)) {
for (const company of data.production_companies) {
await metaCore.addToSet(cid, 'studio', company.name);
}
}
// Add tmdb-verified tag (same as old processor)
await metaCore.addToSet(cid, 'tags', 'tmdb-verified');
// Download poster and backdrop images to plugin output folder
if (data.poster_path && tmdbId) {
const posterResult = await downloadAndHashImage(
data.poster_path,
'poster',
displayTitle,
year,
tmdbId,
metaCore as any
);
if (posterResult) {
await metaCore.setProperty(cid, 'poster', posterResult.cid);
await metaCore.setProperty(cid, 'posterPath', posterResult.path);
console.log(`[tmdb] Set poster CID: ${posterResult.cid}`);
}
}
if (data.backdrop_path && tmdbId) {
const backdropResult = await downloadAndHashImage(
data.backdrop_path,
'backdrop',
displayTitle,
year,
tmdbId,
metaCore as any
);
if (backdropResult) {
await metaCore.setProperty(cid, 'backdrop', backdropResult.cid);
await metaCore.setProperty(cid, 'backdropPath', backdropResult.path);
console.log(`[tmdb] Set backdrop CID: ${backdropResult.cid}`);
}
}
}