radarr/sonarr: stop silent failures, add metadata lookup fallback, diagnostic logs
All checks were successful
Build and Push Docker Image / build (push) Successful in 25s
All checks were successful
Build and Push Docker Image / build (push) Successful in 25s
The real reason 8 Mile landed as Turkish: Radarr WAS being called, but the
call path had three silent failure modes that all looked identical from
outside.
1. try { … } catch { return null } swallowed every error. No log when
Radarr was unreachable, when the API key was wrong, when HTTP returned
404/500, or when JSON parsing failed. A miss and a crash looked the
same: null, fall back to Jellyfin's dub guess.
2. /api/v3/movie?tmdbId=X only queries Radarr's LIBRARY. If the movie is
on disk + in Jellyfin but not actively managed in Radarr, returns [].
We then gave up and used the Jellyfin guess.
3. iso6391To6392 fell back to normalizeLanguage(name.slice(0, 3)) for any
unknown language name — pretending 'Mandarin' → 'man' and 'Flemish' →
'fle' are valid ISO 639-2 codes.
Fixes:
- Both services: fetchJson helper logs HTTP errors with context and the
url (api key redacted), plus catches+logs thrown errors.
- Added a metadata-lookup fallback: /api/v3/movie/lookup/tmdb and
/lookup/imdb for Radarr, /api/v3/series/lookup?term=tvdb:X for Sonarr.
These hit TMDB/TVDB via the arr service for titles not in its library.
- Expanded NAME_TO_639_2: Mandarin/Cantonese → zho, Flemish → nld,
Farsi → fas, plus common European langs that were missing.
- Unknown name → return null (log a warning) instead of a made-up 3-char
code. scan.ts then marks needs_review.
- scan.ts: per-item warn when Radarr/Sonarr miss; per-scan summary line
showing hits/misses/no-provider-id tallies.
Run a scan — the logs will now tell you whether Radarr was called, what
it answered, and why it fell back if it did.
This commit is contained in:
@@ -145,6 +145,17 @@ async function runScan(limit: number | null = null): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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} (${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<string | null> {
|
||||
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<RadarrMovie[]>(`${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<RadarrMovie>(
|
||||
`${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<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.
|
||||
@@ -74,9 +96,12 @@ const NAME_TO_639_2: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
greek: "ell",
|
||||
hebrew: "heb",
|
||||
persian: "fas",
|
||||
farsi: "fas",
|
||||
ukrainian: "ukr",
|
||||
indonesian: "ind",
|
||||
malay: "msa",
|
||||
@@ -98,11 +124,29 @@ const NAME_TO_639_2: Record<string, string> = {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<string | null> {
|
||||
async function fetchJson<T>(url: string, cfg: SonarrConfig, context: string): Promise<T | null> {
|
||||
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<string | null> {
|
||||
// 1. Library: only hits series actively managed in Sonarr.
|
||||
const list = await fetchJson<SonarrSeries[]>(`${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<SonarrSeries[]>(
|
||||
`${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<string, string> = {
|
||||
english: "eng",
|
||||
french: "fra",
|
||||
@@ -53,9 +70,12 @@ const NAME_TO_639_2: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user