bugfix
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user