diff --git a/server/api/scan.ts b/server/api/scan.ts index 4c83543..cb684a6 100644 --- a/server/api/scan.ts +++ b/server/api/scan.ts @@ -4,8 +4,16 @@ import { getAllConfig, getConfig, getDb, setConfig } from "../db/index"; import { log, error as logError, warn } from "../lib/log"; import { analyzeItem } from "../services/analyzer"; import { extractOriginalLanguage, getAllItems, getDevItems, mapStream, normalizeLanguage } from "../services/jellyfin"; -import { getOriginalLanguage as radarrLang } from "../services/radarr"; -import { getOriginalLanguage as sonarrLang } from "../services/sonarr"; +import { + loadLibrary as loadRadarrLibrary, + getOriginalLanguage as radarrLang, + isUsable as radarrUsable, +} from "../services/radarr"; +import { + loadLibrary as loadSonarrLibrary, + getOriginalLanguage as sonarrLang, + isUsable as sonarrUsable, +} from "../services/sonarr"; import type { MediaStream } from "../types"; const app = new Hono(); @@ -152,13 +160,31 @@ async function runScan(limit: number | null = null): Promise { const jellyfinCfg = { url: cfg.jellyfin_url, apiKey: cfg.jellyfin_api_key, userId: cfg.jellyfin_user_id }; const subtitleLanguages = parseLanguageList(cfg.subtitle_languages ?? null, ["eng", "deu", "spa"]); const audioLanguages = parseLanguageList(cfg.audio_languages ?? null, []); - const radarrEnabled = cfg.radarr_enabled === "1"; - const sonarrEnabled = cfg.sonarr_enabled === "1"; + const radarrCfg = { url: cfg.radarr_url, apiKey: cfg.radarr_api_key }; + const sonarrCfg = { url: cfg.sonarr_url, apiKey: cfg.sonarr_api_key }; + // 'enabled' in config means the user toggled it on. Only actually use it + // if the URL+key pass URL parsing — otherwise we'd hit ERR_INVALID_URL on + // every item. Refuse to call invalid endpoints rather than spamming logs. + const radarrEnabled = cfg.radarr_enabled === "1" && radarrUsable(radarrCfg); + const sonarrEnabled = cfg.sonarr_enabled === "1" && sonarrUsable(sonarrCfg); + + if (cfg.radarr_enabled === "1" && !radarrEnabled) { + warn(`Radarr is enabled in config but URL/API key is invalid (url='${cfg.radarr_url}') — skipping Radarr lookups`); + } + if (cfg.sonarr_enabled === "1" && !sonarrEnabled) { + warn(`Sonarr is enabled in config but URL/API key is invalid (url='${cfg.sonarr_url}') — skipping Sonarr lookups`); + } + + // Pre-load both libraries once so per-item lookups are O(1) cache hits + // instead of HTTP round-trips. The previous code called /api/v3/movie + // (the entire library!) once per item that didn't match by tmdbId. + const [radarrLibrary, sonarrLibrary] = await Promise.all([ + radarrEnabled ? loadRadarrLibrary(radarrCfg) : Promise.resolve(null), + sonarrEnabled ? loadSonarrLibrary(sonarrCfg) : Promise.resolve(null), + ]); - // 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"}`, + `External language sources: radarr=${radarrEnabled ? `enabled (${cfg.radarr_url}, ${radarrLibrary?.byTmdbId.size ?? 0} movies in library)` : "disabled"}, sonarr=${sonarrEnabled ? `enabled (${cfg.sonarr_url}, ${sonarrLibrary?.byTvdbId.size ?? 0} series in library)` : "disabled"}`, ); let radarrMisses = 0; let radarrHits = 0; @@ -250,14 +276,15 @@ 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) { + if (jellyfinItem.Type === "Movie" && radarrEnabled && radarrLibrary) { 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 }, + radarrCfg, { tmdbId: tmdbId ?? undefined, imdbId: imdbId ?? undefined }, + radarrLibrary, ); if (lang) { radarrHits++; @@ -274,12 +301,12 @@ async function runScan(limit: number | null = null): Promise { } } - if (jellyfinItem.Type === "Episode" && sonarrEnabled) { + if (jellyfinItem.Type === "Episode" && sonarrEnabled && sonarrLibrary) { 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); + const lang = await sonarrLang(sonarrCfg, tvdbId, sonarrLibrary); if (lang) { sonarrHits++; if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; diff --git a/server/services/radarr.ts b/server/services/radarr.ts index 15e5564..ac7b423 100644 --- a/server/services/radarr.ts +++ b/server/services/radarr.ts @@ -10,7 +10,19 @@ function headers(apiKey: string): Record { return { "X-Api-Key": apiKey }; } +/** True only when url is an http(s) URL and apiKey is non-empty. */ +export function isUsable(cfg: RadarrConfig): boolean { + if (!cfg.apiKey) return false; + try { + const u = new URL(cfg.url); + return u.protocol === "http:" || u.protocol === "https:"; + } catch { + return false; + } +} + export async function testConnection(cfg: RadarrConfig): Promise<{ ok: boolean; error?: string }> { + if (!isUsable(cfg)) return { ok: false, error: "Radarr URL/API key not configured" }; try { const res = await fetch(`${cfg.url}/api/v3/system/status`, { headers: headers(cfg.apiKey), @@ -28,41 +40,68 @@ interface RadarrMovie { originalLanguage?: { name: string; nameTranslated?: string }; } +/** Pre-loaded Radarr library indexed for O(1) per-item lookups during a scan. */ +export interface RadarrLibrary { + byTmdbId: Map; + byImdbId: Map; +} + 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, "***")})`); + warn(`Radarr ${context} → HTTP ${res.status}`); return null; } return (await res.json()) as T; } catch (e) { - logError(`Radarr ${context} failed:`, e); + logError(`Radarr ${context} failed:`, (e as Error).message); return null; } } -/** Returns ISO 639-2 original language or null. */ +/** + * Fetch the full Radarr library once and index by tmdbId + imdbId. The + * caller passes this around for the duration of the scan so per-item + * lookups don't hit Radarr at all when the movie is in the library. + */ +export async function loadLibrary(cfg: RadarrConfig): Promise { + const empty: RadarrLibrary = { byTmdbId: new Map(), byImdbId: new Map() }; + if (!isUsable(cfg)) return empty; + const list = await fetchJson(`${cfg.url}/api/v3/movie`, cfg, "library/all"); + if (!list) return empty; + const byTmdbId = new Map(); + const byImdbId = new Map(); + for (const m of list) { + if (m.tmdbId != null) byTmdbId.set(String(m.tmdbId), m); + if (m.imdbId) byImdbId.set(m.imdbId, m); + } + return { byTmdbId, byImdbId }; +} + +/** + * Returns ISO 639-2 original language or null. Uses the pre-loaded library + * first (no HTTP), falls back to a single metadata-lookup HTTP call when + * the movie isn't in Radarr's library (e.g. on disk + in Jellyfin only). + */ export async function getOriginalLanguage( cfg: RadarrConfig, ids: { tmdbId?: string; imdbId?: string }, + library: RadarrLibrary, ): Promise { - // 1. Library lookup by tmdbId: only hits movies actively managed in Radarr. + // Library hit — no HTTP at all. 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); + const m = library.byTmdbId.get(ids.tmdbId); + if (m?.originalLanguage) return nameToIso(m.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); + const m = library.byImdbId.get(ids.imdbId); + if (m?.originalLanguage) return nameToIso(m.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. + // Missing from library — one metadata-lookup call. Prefer tmdbId since + // it goes straight to TMDB; fall back to imdb if that's all we have. + if (!isUsable(cfg)) return null; if (ids.tmdbId) { const result = await fetchJson( `${cfg.url}/api/v3/movie/lookup/tmdb?tmdbId=${ids.tmdbId}`, @@ -70,9 +109,7 @@ export async function getOriginalLanguage( "lookup/tmdb", ); if (result?.originalLanguage) return nameToIso(result.originalLanguage.name); - } - - if (ids.imdbId) { + } else if (ids.imdbId) { const result = await fetchJson( `${cfg.url}/api/v3/movie/lookup/imdb?imdbId=${ids.imdbId}`, cfg, @@ -137,16 +174,10 @@ const NAME_TO_639_2: Record = { "portuguese (brazil)": "por", }; -/** 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(); 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.`); + if (key.length === 2 || key.length === 3) return normalizeLanguage(key); + warn(`Radarr language name not recognised: '${name}'.`); return null; } diff --git a/server/services/sonarr.ts b/server/services/sonarr.ts index 7878608..eedb17b 100644 --- a/server/services/sonarr.ts +++ b/server/services/sonarr.ts @@ -10,7 +10,19 @@ function headers(apiKey: string): Record { return { "X-Api-Key": apiKey }; } +/** True only when url is an http(s) URL and apiKey is non-empty. */ +export function isUsable(cfg: SonarrConfig): boolean { + if (!cfg.apiKey) return false; + try { + const u = new URL(cfg.url); + return u.protocol === "http:" || u.protocol === "https:"; + } catch { + return false; + } +} + export async function testConnection(cfg: SonarrConfig): Promise<{ ok: boolean; error?: string }> { + if (!isUsable(cfg)) return { ok: false, error: "Sonarr URL/API key not configured" }; try { const res = await fetch(`${cfg.url}/api/v3/system/status`, { headers: headers(cfg.apiKey), @@ -27,28 +39,47 @@ interface SonarrSeries { originalLanguage?: { name: string }; } +/** Pre-loaded Sonarr library indexed for O(1) per-series lookups during a scan. */ +export interface SonarrLibrary { + byTvdbId: Map; +} + async function fetchJson(url: string, cfg: SonarrConfig, context: string): Promise { try { const res = await fetch(url, { headers: headers(cfg.apiKey) }); if (!res.ok) { - warn(`Sonarr ${context} → HTTP ${res.status} (${url.replace(cfg.apiKey, "***")})`); + warn(`Sonarr ${context} → HTTP ${res.status}`); return null; } return (await res.json()) as T; } catch (e) { - logError(`Sonarr ${context} failed:`, e); + logError(`Sonarr ${context} failed:`, (e as Error).message); 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); +export async function loadLibrary(cfg: SonarrConfig): Promise { + const empty: SonarrLibrary = { byTvdbId: new Map() }; + if (!isUsable(cfg)) return empty; + const list = await fetchJson(`${cfg.url}/api/v3/series`, cfg, "library/all"); + if (!list) return empty; + const byTvdbId = new Map(); + for (const s of list) { + if (s.tvdbId != null) byTvdbId.set(String(s.tvdbId), s); + } + return { byTvdbId }; +} - // 2. Metadata lookup via Sonarr's TVDB bridge for series not in the library. +/** Returns ISO 639-2 original language for a series or null. */ +export async function getOriginalLanguage( + cfg: SonarrConfig, + tvdbId: string, + library: SonarrLibrary, +): Promise { + const m = library.byTvdbId.get(tvdbId); + if (m?.originalLanguage) return nameToIso(m.originalLanguage.name); + + if (!isUsable(cfg)) return null; const lookup = await fetchJson( `${cfg.url}/api/v3/series/lookup?term=tvdb%3A${tvdbId}`, cfg,