Files
netfelix-audio-fix/server/services/radarr.ts
Felix Förtsch b8525be015
All checks were successful
Build and Push Docker Image / build (push) Successful in 30s
scan: validate arr URLs upfront, cache library once per scan
Two regressions from the radarr/sonarr fix:

1. ERR_INVALID_URL spam — when radarr_enabled='1' but radarr_url is empty
   or not http(s), every per-item fetch threw TypeError. We caught it but
   still ate the cost (and the log noise) on every movie. New isUsable()
   check on each service: enabled-in-config but URL doesn't parse →
   warn ONCE and skip arr lookups for the whole scan.

2. Per-item HTTP storm — for movies not in Radarr's library we used to
   hit /api/v3/movie (the WHOLE library) again per item, then two
   metadata-lookup calls. With 2000 items that's thousands of extra
   round-trips and the scan crawled. Now: pre-load the Radarr/Sonarr
   library once into Map<tmdbId,..>+Map<imdbId,..>+Map<tvdbId,..>,
   per-item lookups are O(1) memory hits, and only the genuinely-missing
   items make a single lookup-endpoint HTTP call.

The startup line now reports the library size:
  External language sources: radarr=enabled (https://..., 1287 movies in library), sonarr=...
so you can immediately see whether the cache loaded.
2026-04-13 12:06:17 +02:00

184 lines
5.1 KiB
TypeScript

import { error as logError, warn } from "../lib/log";
import { normalizeLanguage } from "./jellyfin";
export interface RadarrConfig {
url: string;
apiKey: string;
}
function headers(apiKey: string): Record<string, string> {
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),
});
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
return { ok: true };
} catch (e) {
return { ok: false, error: String(e) };
}
}
interface RadarrMovie {
tmdbId?: number;
imdbId?: string;
originalLanguage?: { name: string; nameTranslated?: string };
}
/** Pre-loaded Radarr library indexed for O(1) per-item lookups during a scan. */
export interface RadarrLibrary {
byTmdbId: Map<string, RadarrMovie>;
byImdbId: Map<string, RadarrMovie>;
}
async function fetchJson<T>(url: string, cfg: RadarrConfig, context: string): Promise<T | null> {
try {
const res = await fetch(url, { headers: headers(cfg.apiKey) });
if (!res.ok) {
warn(`Radarr ${context} → HTTP ${res.status}`);
return null;
}
return (await res.json()) as T;
} catch (e) {
logError(`Radarr ${context} failed:`, (e as Error).message);
return 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<RadarrLibrary> {
const empty: RadarrLibrary = { byTmdbId: new Map(), byImdbId: new Map() };
if (!isUsable(cfg)) return empty;
const list = await fetchJson<RadarrMovie[]>(`${cfg.url}/api/v3/movie`, cfg, "library/all");
if (!list) return empty;
const byTmdbId = new Map<string, RadarrMovie>();
const byImdbId = new Map<string, RadarrMovie>();
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<string | null> {
// Library hit — no HTTP at all.
if (ids.tmdbId) {
const m = library.byTmdbId.get(ids.tmdbId);
if (m?.originalLanguage) return nameToIso(m.originalLanguage.name);
}
if (ids.imdbId) {
const m = library.byImdbId.get(ids.imdbId);
if (m?.originalLanguage) return nameToIso(m.originalLanguage.name);
}
// 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<RadarrMovie>(
`${cfg.url}/api/v3/movie/lookup/tmdb?tmdbId=${ids.tmdbId}`,
cfg,
"lookup/tmdb",
);
if (result?.originalLanguage) return nameToIso(result.originalLanguage.name);
} else if (ids.imdbId) {
const result = await fetchJson<RadarrMovie>(
`${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.
// Map them to ISO 639-2 codes.
const NAME_TO_639_2: Record<string, string> = {
english: "eng",
french: "fra",
german: "deu",
spanish: "spa",
italian: "ita",
portuguese: "por",
japanese: "jpn",
korean: "kor",
chinese: "zho",
mandarin: "zho",
cantonese: "zho",
arabic: "ara",
russian: "rus",
dutch: "nld",
flemish: "nld",
swedish: "swe",
norwegian: "nor",
danish: "dan",
finnish: "fin",
polish: "pol",
turkish: "tur",
thai: "tha",
hindi: "hin",
hungarian: "hun",
czech: "ces",
romanian: "ron",
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",
"brazilian portuguese": "por",
"portuguese (brazil)": "por",
};
function nameToIso(name: string): string | null {
const key = name.toLowerCase().trim();
if (NAME_TO_639_2[key]) return NAME_TO_639_2[key];
if (key.length === 2 || key.length === 3) return normalizeLanguage(key);
warn(`Radarr language name not recognised: '${name}'.`);
return null;
}