bugfix
This commit is contained in:
+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