diff --git a/server/api/scan.ts b/server/api/scan.ts index 76cf188..f906657 100644 --- a/server/api/scan.ts +++ b/server/api/scan.ts @@ -145,6 +145,17 @@ async function runScan(limit: number | null = null): Promise { const radarrEnabled = cfg.radarr_enabled === "1"; const sonarrEnabled = cfg.sonarr_enabled === "1"; + // Log the external-source situation once per scan so it's obvious from + // logs whether Radarr/Sonarr are actually being consulted. + log( + `External language sources: radarr=${radarrEnabled ? `enabled (${cfg.radarr_url || "NO URL"})` : "disabled"}, sonarr=${sonarrEnabled ? `enabled (${cfg.sonarr_url || "NO URL"})` : "disabled"}`, + ); + let radarrMisses = 0; + let radarrHits = 0; + let sonarrMisses = 0; + let sonarrHits = 0; + let missingProviderIds = 0; + let processed = 0; let errors = 0; let total = 0; @@ -229,26 +240,46 @@ async function runScan(limit: number | null = null): Promise { let needsReview = origLang ? 0 : 1; let authoritative = false; // set when Radarr/Sonarr answers - if (jellyfinItem.Type === "Movie" && radarrEnabled && (tmdbId || imdbId)) { - const lang = await radarrLang( - { url: cfg.radarr_url, apiKey: cfg.radarr_api_key }, - { tmdbId: tmdbId ?? undefined, imdbId: imdbId ?? undefined }, - ); - if (lang) { - if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; - origLang = lang; - origLangSource = "radarr"; - authoritative = true; + if (jellyfinItem.Type === "Movie" && radarrEnabled) { + if (!tmdbId && !imdbId) { + missingProviderIds++; + warn(`No tmdb/imdb id on '${jellyfinItem.Name}' — Radarr lookup skipped`); + } else { + const lang = await radarrLang( + { url: cfg.radarr_url, apiKey: cfg.radarr_api_key }, + { tmdbId: tmdbId ?? undefined, imdbId: imdbId ?? undefined }, + ); + if (lang) { + radarrHits++; + if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; + origLang = lang; + origLangSource = "radarr"; + authoritative = true; + } else { + radarrMisses++; + warn( + `Radarr returned no language for '${jellyfinItem.Name}' (tmdb=${tmdbId ?? "-"} imdb=${imdbId ?? "-"}) — falling back to Jellyfin guess`, + ); + } } } - if (jellyfinItem.Type === "Episode" && sonarrEnabled && tvdbId) { - const lang = await sonarrLang({ url: cfg.sonarr_url, apiKey: cfg.sonarr_api_key }, tvdbId); - if (lang) { - if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; - origLang = lang; - origLangSource = "sonarr"; - authoritative = true; + if (jellyfinItem.Type === "Episode" && sonarrEnabled) { + if (!tvdbId) { + missingProviderIds++; + warn(`No tvdb id on '${jellyfinItem.Name}' — Sonarr lookup skipped`); + } else { + const lang = await sonarrLang({ url: cfg.sonarr_url, apiKey: cfg.sonarr_api_key }, tvdbId); + if (lang) { + sonarrHits++; + if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; + origLang = lang; + origLangSource = "sonarr"; + authoritative = true; + } else { + sonarrMisses++; + warn(`Sonarr returned no language for '${jellyfinItem.Name}' (tvdb=${tvdbId}) — falling back to Jellyfin guess`); + } } } @@ -347,6 +378,9 @@ async function runScan(limit: number | null = null): Promise { setConfig("scan_running", "0"); log(`Scan complete: ${processed} scanned, ${errors} errors`); + log( + ` language sources: radarr hits=${radarrHits} misses=${radarrMisses}, sonarr hits=${sonarrHits} misses=${sonarrMisses}, no provider id=${missingProviderIds}`, + ); emitSse("complete", { scanned: processed, total, errors }); } diff --git a/server/services/radarr.ts b/server/services/radarr.ts index bd6c353..15e5564 100644 --- a/server/services/radarr.ts +++ b/server/services/radarr.ts @@ -1,3 +1,4 @@ +import { error as logError, warn } from "../lib/log"; import { normalizeLanguage } from "./jellyfin"; export interface RadarrConfig { @@ -27,39 +28,60 @@ interface RadarrMovie { originalLanguage?: { name: string; nameTranslated?: string }; } +async function fetchJson(url: string, cfg: RadarrConfig, context: string): Promise { + try { + const res = await fetch(url, { headers: headers(cfg.apiKey) }); + if (!res.ok) { + warn(`Radarr ${context} → HTTP ${res.status} (${url.replace(cfg.apiKey, "***")})`); + return null; + } + return (await res.json()) as T; + } catch (e) { + logError(`Radarr ${context} failed:`, e); + return null; + } +} + /** Returns ISO 639-2 original language or null. */ export async function getOriginalLanguage( cfg: RadarrConfig, ids: { tmdbId?: string; imdbId?: string }, ): Promise { - try { - let movie: RadarrMovie | null = null; - - if (ids.tmdbId) { - const res = await fetch(`${cfg.url}/api/v3/movie?tmdbId=${ids.tmdbId}`, { - headers: headers(cfg.apiKey), - }); - if (res.ok) { - const list = (await res.json()) as RadarrMovie[]; - movie = list[0] ?? null; - } - } - - if (!movie && ids.imdbId) { - const res = await fetch(`${cfg.url}/api/v3/movie`, { - headers: headers(cfg.apiKey), - }); - if (res.ok) { - const list = (await res.json()) as (RadarrMovie & { imdbId?: string })[]; - movie = list.find((m) => m.imdbId === ids.imdbId) ?? null; - } - } - - if (!movie?.originalLanguage) return null; - return iso6391To6392(movie.originalLanguage.name) ?? null; - } catch { - return null; + // 1. Library lookup by tmdbId: only hits movies actively managed in Radarr. + if (ids.tmdbId) { + const list = await fetchJson(`${cfg.url}/api/v3/movie?tmdbId=${ids.tmdbId}`, cfg, "library/tmdb"); + const movie = list?.[0]; + if (movie?.originalLanguage) return nameToIso(movie.originalLanguage.name); } + + // 2. Library lookup by imdbId (sidestep: pull all and filter). + if (ids.imdbId) { + const list = await fetchJson<(RadarrMovie & { imdbId?: string })[]>(`${cfg.url}/api/v3/movie`, cfg, "library/all"); + const movie = list?.find((m) => m.imdbId === ids.imdbId); + if (movie?.originalLanguage) return nameToIso(movie.originalLanguage.name); + } + + // 3. Metadata lookup — asks Radarr to consult TMDB for movies NOT in the + // library. This is the fix for the 8-Mile-in-Jellyfin-but-not-in-Radarr case. + if (ids.tmdbId) { + const result = await fetchJson( + `${cfg.url}/api/v3/movie/lookup/tmdb?tmdbId=${ids.tmdbId}`, + cfg, + "lookup/tmdb", + ); + if (result?.originalLanguage) return nameToIso(result.originalLanguage.name); + } + + if (ids.imdbId) { + const result = await fetchJson( + `${cfg.url}/api/v3/movie/lookup/imdb?imdbId=${ids.imdbId}`, + cfg, + "lookup/imdb", + ); + if (result?.originalLanguage) return nameToIso(result.originalLanguage.name); + } + + return null; } // Radarr returns language names like "English", "French", "German", etc. @@ -74,9 +96,12 @@ const NAME_TO_639_2: Record = { japanese: "jpn", korean: "kor", chinese: "zho", + mandarin: "zho", + cantonese: "zho", arabic: "ara", russian: "rus", dutch: "nld", + flemish: "nld", swedish: "swe", norwegian: "nor", danish: "dan", @@ -91,6 +116,7 @@ const NAME_TO_639_2: Record = { greek: "ell", hebrew: "heb", persian: "fas", + farsi: "fas", ukrainian: "ukr", indonesian: "ind", malay: "msa", @@ -98,11 +124,29 @@ const NAME_TO_639_2: Record = { catalan: "cat", tamil: "tam", telugu: "tel", + icelandic: "isl", + croatian: "hrv", + bulgarian: "bul", + serbian: "srp", + slovak: "slk", + slovenian: "slv", + latvian: "lav", + lithuanian: "lit", + estonian: "est", "brazilian portuguese": "por", "portuguese (brazil)": "por", }; -function iso6391To6392(name: string): string | null { +/** Map a Radarr language name to ISO 639-2 or null. Logs unmapped names. */ +function nameToIso(name: string): string | null { const key = name.toLowerCase().trim(); - return NAME_TO_639_2[key] ?? normalizeLanguage(key.slice(0, 3)) ?? null; + if (NAME_TO_639_2[key]) return NAME_TO_639_2[key]; + // If Radarr ever returns a bare code (e.g., "en", "eng"), normalizeLanguage + // will pass it through. Anything else is unknown — return null instead of + // guessing so scan.ts can mark needs_review. + if (key.length === 2 || key.length === 3) { + return normalizeLanguage(key); + } + warn(`Radarr language name not recognised: '${name}'. Add it to NAME_TO_639_2 or review upstream.`); + return null; } diff --git a/server/services/sonarr.ts b/server/services/sonarr.ts index cf61955..7878608 100644 --- a/server/services/sonarr.ts +++ b/server/services/sonarr.ts @@ -1,3 +1,4 @@ +import { error as logError, warn } from "../lib/log"; import { normalizeLanguage } from "./jellyfin"; export interface SonarrConfig { @@ -26,23 +27,39 @@ interface SonarrSeries { originalLanguage?: { name: string }; } -/** Returns ISO 639-2 original language for a series or null. */ -export async function getOriginalLanguage(cfg: SonarrConfig, tvdbId: string): Promise { +async function fetchJson(url: string, cfg: SonarrConfig, context: string): Promise { try { - const res = await fetch(`${cfg.url}/api/v3/series?tvdbId=${tvdbId}`, { - headers: headers(cfg.apiKey), - }); - if (!res.ok) return null; - - const list = (await res.json()) as SonarrSeries[]; - const series = list[0]; - if (!series?.originalLanguage) return null; - return languageNameToCode(series.originalLanguage.name) ?? null; - } catch { + const res = await fetch(url, { headers: headers(cfg.apiKey) }); + if (!res.ok) { + warn(`Sonarr ${context} → HTTP ${res.status} (${url.replace(cfg.apiKey, "***")})`); + return null; + } + return (await res.json()) as T; + } catch (e) { + logError(`Sonarr ${context} failed:`, e); return null; } } +/** Returns ISO 639-2 original language for a series or null. */ +export async function getOriginalLanguage(cfg: SonarrConfig, tvdbId: string): Promise { + // 1. Library: only hits series actively managed in Sonarr. + const list = await fetchJson(`${cfg.url}/api/v3/series?tvdbId=${tvdbId}`, cfg, "library/tvdb"); + const series = list?.[0]; + if (series?.originalLanguage) return nameToIso(series.originalLanguage.name); + + // 2. Metadata lookup via Sonarr's TVDB bridge for series not in the library. + const lookup = await fetchJson( + `${cfg.url}/api/v3/series/lookup?term=tvdb%3A${tvdbId}`, + cfg, + "lookup/tvdb", + ); + const fromLookup = lookup?.find((s) => String(s.tvdbId ?? "") === String(tvdbId)) ?? lookup?.[0]; + if (fromLookup?.originalLanguage) return nameToIso(fromLookup.originalLanguage.name); + + return null; +} + const NAME_TO_639_2: Record = { english: "eng", french: "fra", @@ -53,9 +70,12 @@ const NAME_TO_639_2: Record = { japanese: "jpn", korean: "kor", chinese: "zho", + mandarin: "zho", + cantonese: "zho", arabic: "ara", russian: "rus", dutch: "nld", + flemish: "nld", swedish: "swe", norwegian: "nor", danish: "dan", @@ -70,13 +90,29 @@ const NAME_TO_639_2: Record = { greek: "ell", hebrew: "heb", persian: "fas", + farsi: "fas", ukrainian: "ukr", indonesian: "ind", malay: "msa", vietnamese: "vie", + catalan: "cat", + tamil: "tam", + telugu: "tel", + icelandic: "isl", + croatian: "hrv", + bulgarian: "bul", + serbian: "srp", + slovak: "slk", + slovenian: "slv", + latvian: "lav", + lithuanian: "lit", + estonian: "est", }; -function languageNameToCode(name: string): string | null { +function nameToIso(name: string): string | null { const key = name.toLowerCase().trim(); - return NAME_TO_639_2[key] ?? normalizeLanguage(key.slice(0, 3)) ?? null; + if (NAME_TO_639_2[key]) return NAME_TO_639_2[key]; + if (key.length === 2 || key.length === 3) return normalizeLanguage(key); + warn(`Sonarr language name not recognised: '${name}'.`); + return null; }