/** * TMDB Plugin * * Fetches metadata from The Movie Database (TMDB) API. * Supports both v3 API keys and v4 Bearer tokens. * Downloads poster and backdrop images via WebDAV to /files/plugin/tmdb/ * * ============================================================================ * PLUGIN FILE ACCESS ARCHITECTURE * ============================================================================ * * File access via WebDAV, on the meta-core named by each /process request * (its /urls -> webdavUrlInternal; WEBDAV_URL overrides): * - Read media files: GET /webdav/watch/... or /webdav/test/... * - Write output: PUT /webdav/plugin/tmdb/... * - Cache: Local /cache mount (for JSON cache files) * * Benefits: * - No output mount needed on plugin containers * - Consistent read/write architecture via HTTP * - Works in any orchestration environment * - Posters land in the same core the CIDs are written to, always * * ============================================================================ * * Matches old TMDBProcessor output: * - tmdbid, imdbid * - title, originalTitle, movieYear, releasedate * - titles// key-set: localized + original title + every AKA * - plot/eng, rating * - genres (add), studio (add), tags (add) * - poster, backdrop (CID hashes of downloaded images) * - posters/{lang3}/{cid} (alternative posters as url locators, `poster` among them) */ import axios from 'axios'; import { createHash } from 'crypto'; import * as path from 'path'; import type { PluginManifest, ProcessRequest, CallbackPayload } from './types.js'; import { MetaCoreClient } from './meta-core-client.js'; import { readJson, writeJson } from './cache.js'; import { getWebDAVClient, WebDAVClient } from './webdav-client.js'; import { base32LowerMultibase } from './cid.js'; import { posterMembers } from './posters.js'; const IMAGE_BASE_URL = 'https://image.tmdb.org/t/p/original'; /** * ============================================================================ * PLUGIN OUTPUT PATH - Written via WebDAV * ============================================================================ * Output files (posters, backdrops) are written via WebDAV PUT requests, to the * WebDAV of the meta-core that issued the /process call. * Path: /files/plugin/tmdb/ * CACHE_PATH (/cache) - Local mount for plugin cache (handled by cache.ts) * ============================================================================ */ const PLUGIN_OUTPUT_WEBDAV_PATH = '/files/plugin/tmdb'; /** * Compute midhash256 CID via WebDAV */ async function computeMidHash256WebDAV(client: WebDAVClient, filePath: string): Promise { const SAMPLE_SIZE = 1024 * 1024; // 1MB // Get file size via HTTP HEAD const stats = await client.stat(filePath); const fileSize = stats.size; // Read sample data via HTTP Range request let sampleData: Buffer; if (fileSize <= SAMPLE_SIZE) { // Small file: read entire content sampleData = await client.readBytes(filePath, 0, fileSize - 1); } else { // Large file: read middle 1MB const middleOffset = Math.floor((fileSize - SAMPLE_SIZE) / 2); sampleData = await client.readBytes(filePath, middleOffset, middleOffset + SAMPLE_SIZE - 1); } return computeMidHash256FromData(fileSize, sampleData); } /** * Compute midhash256 CID from file size and sample data */ function computeMidHash256FromData(fileSize: number, sampleData: Buffer): string { // varint encoding of 0x1000 (4096) for both codec and hash function code const MIDHASH_VARINT = Buffer.from([0x80, 0x20]); // Create size buffer (64-bit big-endian) const sizeBuffer = Buffer.allocUnsafe(8); sizeBuffer.writeBigUInt64BE(BigInt(fileSize), 0); // Compute SHA-256 hash of [size + sample] const hashInput = Buffer.concat([sizeBuffer, sampleData]); const hashBuffer = createHash('sha256').update(hashInput).digest(); // Build CIDv1: version (0x01) + codec (varint) + multihash // Codec 0x1000 = varint [0x80, 0x20] // Multihash: function-code (varint 0x1000) + length (0x20) + hash const cidBytes = Buffer.concat([ Buffer.from([0x01]), // CIDv1 MIDHASH_VARINT, // codec 0x1000 as varint MIDHASH_VARINT, // hash function code 0x1000 as varint Buffer.from([0x20]), // 32 bytes digest length hashBuffer ]); // Encode as base32lower with 'b' prefix (multibase) return base32LowerMultibase(cidBytes); } export const manifest: PluginManifest = { id: 'tmdb', name: 'TMDB Metadata', version: '1.0.0', description: 'Fetches metadata from The Movie Database (TMDB) API', author: 'MetaMesh', dependencies: ['file-info', 'filename-parser'], priority: 30, color: '#01B4E4', defaultQueue: 'background', timeout: 60000, schema: { tmdbid: { label: 'TMDB ID', type: 'string', readonly: true }, imdbid: { label: 'IMDB ID', type: 'string' }, originalTitle: { label: 'Original Title', type: 'string' }, movieYear: { label: 'Release Year', type: 'number' }, releasedate: { label: 'Release Date', type: 'string' }, rating: { label: 'Vote Average', type: 'string', readonly: true }, 'plot/eng': { label: 'Plot (English)', type: 'string', readonly: true }, }, config: { apiKey: { type: 'string', label: 'TMDB API Key or Bearer Token', required: true, secret: true, }, language: { type: 'select', 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'; // ISO 639-1 → ISO 639-3 (`lang3`, the 639-2/T form where B/T differ) for the // languages TMDB commonly returns. Unknown codes map to nothing. const ISO639_1_TO_3: Record = { en: 'eng', ja: 'jpn', fr: 'fra', de: 'deu', es: 'spa', it: 'ita', ru: 'rus', ko: 'kor', zh: 'zho', pt: 'por', nl: 'nld', sv: 'swe', no: 'nor', da: 'dan', fi: 'fin', pl: 'pol', tr: 'tur', cs: 'ces', hu: 'hun', el: 'ell', he: 'heb', th: 'tha', vi: 'vie', id: 'ind', uk: 'ukr', ro: 'ron', ar: 'ara', fa: 'fas', hi: 'hin', ta: 'tam', te: 'tel', ms: 'msa', tl: 'tgl', ca: 'cat', bg: 'bul', hr: 'hrv', sr: 'srp', sk: 'slk', sl: 'slv', et: 'est', lv: 'lav', lt: 'lit', is: 'isl', ga: 'gle', cn: 'zho', }; function iso639_1to3(code: string | undefined | null): string | undefined { return code ? ISO639_1_TO_3[code.trim().toLowerCase()] : undefined; } // TMDB keys AKAs by MARKET (`iso_3166_1`), not language. Only markets that // clearly imply one language are mapped; a multilingual or unknown market // (CA, CH, BE, IN, HK, SG, …, or empty) files its AKA under `und` rather than // guessing (METADATA_KEYS.md §3). const MARKET_TO_LANG3: Record = { US: 'eng', GB: 'eng', AU: 'eng', NZ: 'eng', IE: 'eng', FR: 'fra', DE: 'deu', AT: 'deu', ES: 'spa', MX: 'spa', AR: 'spa', CO: 'spa', CL: 'spa', PE: 'spa', VE: 'spa', IT: 'ita', BR: 'por', PT: 'por', JP: 'jpn', KR: 'kor', CN: 'zho', TW: 'zho', RU: 'rus', NL: 'nld', SE: 'swe', NO: 'nor', DK: 'dan', FI: 'fin', PL: 'pol', TR: 'tur', CZ: 'ces', HU: 'hun', GR: 'ell', IL: 'heb', TH: 'tha', VN: 'vie', ID: 'ind', UA: 'ukr', RO: 'ron', IR: 'fas', SA: 'ara', EG: 'ara', AE: 'ara', BG: 'bul', HR: 'hrv', RS: 'srp', SK: 'slk', SI: 'slv', EE: 'est', LV: 'lav', LT: 'lit', IS: 'isl', }; function marketToLang3(market: string | undefined | null): string { return (market && MARKET_TO_LANG3[market.trim().toUpperCase()]) || 'und'; } /** * `titles//` key-set member key (METADATA_KEYS.md §3): trimmed, * whitespace collapsed, `/` (the key-set separator) written as U+2215 `∕`. * `undefined` when nothing is left to name. */ function titleMemberKey(lang3: string, name: string | undefined | null): string | undefined { const clean = (name ?? '').trim().replace(/\s+/g, ' ').replace(/\//g, '\u2215'); return clean ? `titles/${lang3}/${clean}` : undefined; } let apiKey: string | null = null; let isV4Token = false; let metadataLanguage = 'en-US'; export function configure(config: Record): void { console.log(`[tmdb] Received config keys: ${Object.keys(config).join(', ')}`); apiKey = config.apiKey as string || null; if (apiKey) { isV4Token = apiKey.startsWith('eyJ'); console.log(`[tmdb] API key configured (type: ${isV4Token ? 'v4 token' : 'v3 key'}, length: ${apiKey.length})`); } else { console.log(`[tmdb] WARNING: No API key in config`); } const lang = config.language as string || 'en'; 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 = {}) { if (isV4Token) { return { headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, params, }; } return { params: { api_key: apiKey, ...params } }; } async function findByImdbId(imdbId: string): Promise { try { const config = getAxiosConfig({ language: metadataLanguage, external_source: 'imdb_id' }); const response = await axios.get(`${BASE_URL}/find/${imdbId}`, config); const result = response.data.movie_results?.[0] || response.data.tv_results?.[0]; if (result) { const mediaType = response.data.movie_results?.[0] ? 'movie' : 'tv'; return getByTmdbId(result.id.toString(), mediaType); } } catch { return null; } return null; } async function getByTmdbId(tmdbId: string, mediaType: string): Promise { try { // `alternative_titles` rides along so the AKAs land in `titles/*` (§3). // `images` rides the same call too: the poster candidates for the // `posters/{lang3}/{cid}` key-set (§6). `language` would otherwise filter // them to one language, so widen it to the configured one, English and // textless (`null`). const lang1 = metadataLanguage.split('-')[0]; const config = getAxiosConfig({ language: metadataLanguage, append_to_response: 'alternative_titles,images', include_image_language: [...new Set([lang1, 'en'])].join(',') + ',null', }); const response = await axios.get(`${BASE_URL}/${mediaType}/${tmdbId}`, config); return response.data; } catch { return null; } } async function searchByTitle(title: string, year?: string, videoType?: string): Promise { try { const mediaType = videoType === 'tvshow' || videoType === 'tv' ? 'tv' : 'movie'; const endpoint = `search/${mediaType}`; const params: Record = { language: metadataLanguage, query: title }; if (year) params.year = year; const config = getAxiosConfig(params); const response = await axios.get(`${BASE_URL}/${endpoint}`, config); const results = response.data.results || []; if (results.length > 0) { return getByTmdbId(results[0].id.toString(), mediaType); } } catch { return null; } return null; } /** * Sanitize filename by removing invalid characters */ function sanitizeFilename(name: string): string { return name .replace(/[<>:"/\\|?*]/g, '') // Remove invalid chars .replace(/\s+/g, ' ') // Normalize whitespace .trim(); } /** * Download an image from URL and upload to WebDAV * * Images are written via WebDAV PUT to /files/plugin/tmdb/ * This allows output without mounting a volume to the plugin container. */ async function downloadImageToWebDAV( client: WebDAVClient, imageUrl: string, webdavPath: string ): Promise { try { // Download image as buffer const response = await axios({ method: 'get', url: imageUrl, responseType: 'arraybuffer', timeout: 30000, }); // Upload to WebDAV await client.writeFile(webdavPath, Buffer.from(response.data)); console.log(`[tmdb] Uploaded image to WebDAV: ${webdavPath}`); return true; } catch (error) { console.error(`[tmdb] Failed to download/upload image from ${imageUrl}: ${error instanceof Error ? error.message : String(error)}`); return false; } } /** * Download and hash an image, returning its CID. * * Images are written via WebDAV PUT to /files/plugin/tmdb/. The watcher * indexes that directory as a first-class file root, so each downloaded * image becomes a normal metadata entry with a midhash256 alias. The * editor's CID preview then resolves through standard CID resolution — * no need to store any path in the parent file's metadata. */ async function downloadAndHashImage( webdavClient: WebDAVClient | null, imagePath: string, imageType: string, title: string, year: string | undefined, tmdbId: string ): Promise { if (!imagePath) { return null; } if (!webdavClient) { console.error(`[tmdb] WebDAV client not available, cannot write output files`); return null; } const imageUrl = `${IMAGE_BASE_URL}${imagePath}`; const ext = path.extname(imagePath) || '.jpg'; // Build filename: (<year>)[tmdb<tmdbId>]_<type>.jpg // Note: Don't include file CID to avoid duplicate posters for same movie const safeName = sanitizeFilename(title); const yearStr = year ? ` (${year})` : ''; const filename = `${safeName}${yearStr}[tmdb${tmdbId}]_${imageType}${ext}`; // WebDAV path for output file const webdavPath = `${PLUGIN_OUTPUT_WEBDAV_PATH}/${filename}`; // Check if file already exists via WebDAV HEAD const exists = await webdavClient.exists(webdavPath); if (exists) { console.log(`[tmdb] Image already exists: ${webdavPath}`); } else { // Download the image and upload via WebDAV const uploaded = await downloadImageToWebDAV(webdavClient, imageUrl, webdavPath); if (!uploaded) { return null; } } // Compute midhash256 CID via WebDAV (reads file back to compute hash) try { const cid = await computeMidHash256WebDAV(webdavClient, webdavPath); console.log(`[tmdb] Image ${imageType} CID: ${cid}`); return cid; } catch (e) { console.error(`[tmdb] Failed to compute CID for ${webdavPath}: ${e instanceof Error ? e.message : String(e)}`); } return null; } export async function process( request: ProcessRequest, sendCallback: (payload: CallbackPayload) => Promise<void> ): Promise<void> { const startTime = Date.now(); const metaCore = new MetaCoreClient(request.metaCoreUrl); // WebDAV of the very core we're enriching, so poster blobs and the poster // CIDs we write can never point at different cores. Cached per core URL. const webdavClient = await getWebDAVClient(request.metaCoreUrl); try { const { cid, existingMeta } = request; if (!apiKey) { await sendCallback({ taskId: request.taskId, status: 'skipped', duration: Date.now() - startTime, reason: 'No API key configured', }); return; } if (existingMeta?.fileType !== 'video') { await sendCallback({ taskId: request.taskId, status: 'skipped', duration: Date.now() - startTime, reason: 'Not a video file', }); return; } // Skip only if FULLY enriched already (tmdbid AND poster), unless forced. // A record carrying a tmdbid but no poster — e.g. a gateway anchor or // jellyfin-nfo that set only the id — still runs, so the full cycle // (poster download + plot/genres) completes via getByTmdbId below. if (existingMeta?.tmdbid && existingMeta?.poster && !forceRecompute) { await sendCallback({ taskId: request.taskId, status: 'skipped', duration: Date.now() - startTime, reason: 'Already has TMDB data', }); return; } if (forceRecompute) { console.log(`[tmdb] Force recompute enabled for ${request.filePath ?? existingMeta?.fileName ?? cid}`); } const label = request.filePath ?? existingMeta?.fileName ?? cid; // Exact-file cache, keyed by the file's byte-hash. Only records carrying // real file bytes have `cid_midhash256`; gateway/usenet records don't — // which is exactly why the per-tmdbid cache below exists. Stores a full // entry now, but tolerates the legacy raw-payload format from older builds. const midhash = existingMeta?.['cid_midhash256']; if (midhash && !forceRecompute) { const cached = await readJson<any>(`${midhash}_aka_tmdb.json`); if (cached) { console.log(`[tmdb] Using byte-hash cached TMDB data for ${label}`); const isEntry = cached.data !== undefined; await applyTmdbData( metaCore, webdavClient, cid, isEntry ? cached.data : cached, // Legacy raw payloads carry no image CIDs → let applyTmdbData recompute. isEntry ? { posterCid: cached.posterCid, backdropCid: cached.backdropCid } : undefined ); await sendCallback({ taskId: request.taskId, status: 'completed', duration: Date.now() - startTime, }); return; } } // Resolve the show/movie entry, deduped per tmdbid. A record that already // carries a tmdbid anchor (every gateway torrent/usenet hit does) takes // the cached + single-flighted fast path, so N releases of one show share // ONE TMDB fetch + poster seed instead of N. let entry: TmdbCacheEntry | null = null; const knownTmdbId = existingMeta?.tmdbid; if (knownTmdbId) { const mediaType = existingMeta?.videoType === 'tvshow' || existingMeta?.videoType === 'tv' ? 'tv' : 'movie'; entry = await resolveEntryById(webdavClient, knownTmdbId, mediaType); } // Fallback: no usable id → resolve by IMDB id or fuzzy title, then cache // the result by its resolved tmdbid so later releases of the same show // take the fast path above. if (!entry) { let tmdbData: any = null; const imdbId = existingMeta?.imdbid; if (imdbId) { tmdbData = await findByImdbId(imdbId); } if (!tmdbData) { let title = existingMeta?.originalTitle || existingMeta?.fileName; const year = existingMeta?.movieYear; const videoType = existingMeta?.videoType; // Strip trailing year from title if present ("Sintel 2010" -> "Sintel"). if (title && year) { const yearRegex = new RegExp(`\\s*[\\(\\[]?${year}[\\)\\]]?\\s*$`); title = title.replace(yearRegex, '').trim(); } if (title) { tmdbData = await searchByTitle(title, year, videoType); } } if (tmdbData) { const images = await computeImageCids(webdavClient, tmdbData); entry = { data: tmdbData, ...images }; if (tmdbData.id) { await writeJson(tmdbCacheKey(String(tmdbData.id), mediaTypeOf(tmdbData)), entry); } } } if (entry) { await applyTmdbData(metaCore, webdavClient, cid, entry.data, { posterCid: entry.posterCid, backdropCid: entry.backdropCid, }); // Keep the exact-file cache warm too, so a future re-enrichment of // this same file skips straight to the byte-hash hit. if (midhash) { await writeJson(`${midhash}_aka_tmdb.json`, entry); } console.log(`[tmdb] Enriched ${label} (tmdbid=${entry.data.id})`); } else { console.log(`[tmdb] No TMDB match found for ${label}`); } await sendCallback({ taskId: request.taskId, status: 'completed', duration: Date.now() - startTime, }); } catch (error) { await sendCallback({ taskId: request.taskId, status: 'failed', duration: Date.now() - startTime, error: error instanceof Error ? error.message : String(error), }); } } /** Resolved TMDB payload plus the seeded poster/backdrop CIDs, cached per * tmdbid. Every torrent/usenet release of an episode carries the same * `tmdbid`, and TMDB only gives us show/movie-level fields (poster, plot, * genres — no per-episode data), so N releases can share ONE fetch + seed. */ interface TmdbCacheEntry { data: any; posterCid: string | null; backdropCid: string | null; } // In-process single-flight: collapses concurrent resolves of the same key onto // one promise, so a burst of same-tmdbid records that all miss the cold cache // triggers a single TMDB fetch (no thundering herd) — the rest await it. const tmdbEntryInflight = new Map<string, Promise<TmdbCacheEntry | null>>(); // Persistent cache filename for a resolved show/movie. Includes mediaType + // language so a tv/movie id clash or a language switch can't return stale text. function tmdbCacheKey(tmdbId: string, mediaType: string): string { // `_aka`: entries written before `alternative_titles` was appended carry no // AKAs, so the name moved to make them refetch once. return `tmdbid_${mediaType}_${tmdbId}_${metadataLanguage}_aka_tmdb.json`; } // Media type from a raw TMDB payload's own shape (tv payloads carry // name/first_air_date). Mirrors the check in applyTmdbData. function mediaTypeOf(data: any): string { return data?.first_air_date !== undefined || data?.name !== undefined || data?.original_name !== undefined ? 'tv' : 'movie'; } // Download + hash the poster/backdrop for a resolved payload. Record-independent // (the image filename is keyed by tmdbId, so the blob dedups across records), so // the CIDs can be computed once per show and reused for every release. async function computeImageCids( webdavClient: WebDAVClient | null, data: any ): Promise<{ posterCid: string | null; backdropCid: string | null }> { const tmdbId = data?.id ? String(data.id) : ''; const displayTitle = data?.title || data?.name || data?.original_title || data?.original_name || 'Unknown'; const releaseDate = data?.release_date || data?.first_air_date; const year = releaseDate ? releaseDate.split('-')[0] : undefined; const posterCid = data?.poster_path && tmdbId ? await downloadAndHashImage(webdavClient, data.poster_path, 'poster', displayTitle, year, tmdbId) : null; const backdropCid = data?.backdrop_path && tmdbId ? await downloadAndHashImage(webdavClient, data.backdrop_path, 'backdrop', displayTitle, year, tmdbId) : null; return { posterCid, backdropCid }; } // Resolve the full entry (payload + image CIDs) for a KNOWN tmdbid, served from // the persistent per-tmdbid cache when possible and single-flighted otherwise. // This is the hot path for gateway records (torrent + usenet), which always // carry a `tmdbid` anchor — so N releases of one show share one TMDB fetch. async function resolveEntryById( webdavClient: WebDAVClient | null, tmdbId: string, mediaType: string ): Promise<TmdbCacheEntry | null> { const key = tmdbCacheKey(tmdbId, mediaType); if (!forceRecompute) { const cached = await readJson<TmdbCacheEntry>(key); if (cached?.data) { return cached; } } const inflight = tmdbEntryInflight.get(key); if (inflight) { return inflight; } const pending = (async (): Promise<TmdbCacheEntry | null> => { console.log(`[tmdb] Resolving TMDB ${mediaType}/${tmdbId} (cache miss)`); const data = await getByTmdbId(tmdbId, mediaType); if (!data) { return null; } const images = await computeImageCids(webdavClient, data); const entry: TmdbCacheEntry = { data, ...images }; await writeJson(key, entry); return entry; })(); tmdbEntryInflight.set(key, pending); try { return await pending; } finally { tmdbEntryInflight.delete(key); } } /** * Apply TMDB data to KV store using same keys as old TMDBProcessor */ async function applyTmdbData( metaCore: MetaCoreClient, webdavClient: WebDAVClient | null, cid: string, data: any, images?: { posterCid: string | null; backdropCid: string | null } ): Promise<{ posterCid: string | null; backdropCid: string | null }> { 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; // Authoritative media type, stamped from TMDB's own payload shape. TV // payloads carry `name`/`first_air_date`/`original_name`; movie payloads // carry `title`/`release_date`/`original_title`. This overrides whatever the // filename-parser guessed (`videoType`/`contentKind`), which it derives from // a parsed season/episode — frequently absent for anime fansubs (absolute // numbering, batch packs), the root cause of series being mislabeled films // downstream in meta-watch. `videoType` (movie|tvshow) and `contentKind` // (movie|episode) mirror the filename-parser's keys so consumers read one // field regardless of the enrichment source. const isTv = data.first_air_date !== undefined || data.name !== undefined || data.original_name !== undefined; metadata.videoType = isTv ? 'tvshow' : 'movie'; metadata.contentKind = isTv ? 'episode' : 'movie'; // `domain` is written in the same breath as `contentKind` // (METADATA_KEYS.md §1) — it is the key meta-watch filters its wall on, // and TMDB is precisely the identity graph that decides film vs tv here. metadata.domain = isTv ? 'tv' : 'film'; // 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; } // Every clean name as a `titles/<lang3>/<name>` key-set member // (METADATA_KEYS.md §3): the localized title under the configured metadata // language, the original title under TMDB's `original_language`, and each // AKA under the language its market implies (`und` when it doesn't). const addTitleMember = (lang3: string, name: string | undefined) => { const key = titleMemberKey(lang3, name); if (key) metadata[key] = 'true'; }; addTitleMember(iso639_1to3(metadataLanguage.split('-')[0]) ?? 'und', localizedTitle); addTitleMember(iso639_1to3(data.original_language) ?? 'und', originalTitle); const akas = data.alternative_titles?.titles ?? data.alternative_titles?.results; if (Array.isArray(akas)) { for (const aka of akas) { addTitleMember(marketToLang3(aka?.iso_3166_1), aka?.title); } } // 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 langKey = iso639_1to3(metadataLanguage.split('-')[0]); if (langKey) 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'); // Poster/backdrop CIDs. Reuse the precomputed values when the caller already // seeded them (a per-tmdbid cache hit — so we don't re-download + re-hash the // same image for every release of a show); otherwise download + hash now. // The image filename is keyed by tmdbId, so the blob is deduped either way — // this just also skips the WebDAV round-trips. The watcher picks the images // up as first-class file entries, so storing the CID is all that's needed. let posterCid = images ? images.posterCid : null; if (!images && data.poster_path && tmdbId) { posterCid = await downloadAndHashImage(webdavClient, data.poster_path, 'poster', displayTitle, year, tmdbId); } if (posterCid) { await metaCore.setProperty(cid, 'poster', posterCid); console.log(`[tmdb] Set poster CID: ${posterCid}`); // The alternatives, as the `posters/{lang3}/{cid}` key-set with `poster` // among them (METADATA_KEYS.md §6). Leaf keys through PATCH — never // addToSet, which writes the legacy comma-joined csv-set. Empty for a // cached payload from before the `images` append: no backfill by design. const members = posterMembers(data, posterCid); if (Object.keys(members).length > 0) { await metaCore.mergeMetadata(cid, members); } } let backdropCid = images ? images.backdropCid : null; if (!images && data.backdrop_path && tmdbId) { backdropCid = await downloadAndHashImage(webdavClient, data.backdrop_path, 'backdrop', displayTitle, year, tmdbId); } if (backdropCid) { await metaCore.setProperty(cid, 'backdrop', backdropCid); console.log(`[tmdb] Set backdrop CID: ${backdropCid}`); } return { posterCid, backdropCid }; }