Files
metamesh-plugin-tmdb/src/plugin.ts
T
Worph e4fcb2bfde
Build and Push Docker Image / build (push) Failing after 14s
align with metadata standard
2026-09-13 15:56:06 +02:00

795 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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/<lang3>/<name> 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/<filename>
* 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<string> {
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<string, string> = {
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<string, string> = {
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/<lang3>/<name>` 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<string, unknown>): 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<string, string> = {}) {
if (isV4Token) {
return {
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
params,
};
}
return { params: { api_key: apiKey, ...params } };
}
async function findByImdbId(imdbId: string): Promise<any> {
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<any> {
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<any> {
try {
const mediaType = videoType === 'tvshow' || videoType === 'tv' ? 'tv' : 'movie';
const endpoint = `search/${mediaType}`;
const params: Record<string, string> = { 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<boolean> {
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<string | null> {
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: <title> (<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 };
}