Files
netfelix-audio-fix/server/services/sonarr.ts
Felix Förtsch cafb3852a1
All checks were successful
Build and Push Docker Image / build (push) Successful in 25s
radarr/sonarr: stop silent failures, add metadata lookup fallback, diagnostic logs
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.
2026-04-13 11:46:26 +02:00

119 lines
3.0 KiB
TypeScript

import { error as logError, warn } from "../lib/log";
import { normalizeLanguage } from "./jellyfin";
export interface SonarrConfig {
url: string;
apiKey: string;
}
function headers(apiKey: string): Record<string, string> {
return { "X-Api-Key": apiKey };
}
export async function testConnection(cfg: SonarrConfig): Promise<{ ok: boolean; error?: string }> {
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 SonarrSeries {
tvdbId?: number;
originalLanguage?: { name: string };
}
async function fetchJson<T>(url: string, cfg: SonarrConfig, context: string): Promise<T | null> {
try {
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",
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",
};
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(`Sonarr language name not recognised: '${name}'.`);
return null;
}