Files
netfelix-audio-fix/server/services/sonarr.ts
T
felixfoertsch 0d560743f3
Build and Push Docker Image / build (push) Successful in 3m31s
fix manual language overrides wiped on rescan, use series tvdb for sonarr, split seriescard controls
- rescan: skip jellyfin/radarr/sonarr lookups when orig_lang_source='manual' so user pins survive webhook + full scans
- jellyfin: request SeriesProviderIds so episodes can resolve to the series-level tvdb id
- sonarr: drop the lookup[0] fallback that silently returned unrelated shows on tvdb misses
- seriescard: split badges+language and approve buttons onto separate rows; seasongroup header wraps with ml-auto so buttons don't overflow the narrow pipeline column
- tests: cover manual override preservation and episode → series tvdb resolution

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:41:08 +02:00

153 lines
4.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 };
}
/** 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),
});
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 };
}
/** Pre-loaded Sonarr library indexed for O(1) per-series lookups during a scan. */
export interface SonarrLibrary {
byTvdbId: Map<string, SonarrSeries>;
}
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}`);
return null;
}
return (await res.json()) as T;
} catch (e) {
logError(`Sonarr ${context} failed:`, (e as Error).message);
return null;
}
}
export async function loadLibrary(cfg: SonarrConfig): Promise<SonarrLibrary> {
const empty: SonarrLibrary = { byTvdbId: new Map() };
if (!isUsable(cfg)) return empty;
const list = await fetchJson<SonarrSeries[]>(`${cfg.url}/api/v3/series`, cfg, "library/all");
if (!list) return empty;
const byTvdbId = new Map<string, SonarrSeries>();
for (const s of list) {
if (s.tvdbId != null) byTvdbId.set(String(s.tvdbId), s);
}
return { byTvdbId };
}
/** Returns ISO 639-2 original language for a series or null. */
export async function getOriginalLanguage(
cfg: SonarrConfig,
tvdbId: string,
library: SonarrLibrary,
): Promise<string | null> {
const m = library.byTvdbId.get(tvdbId);
if (m?.originalLanguage) return nameToIso(m.originalLanguage.name);
if (!isUsable(cfg)) return null;
const lookup = await fetchJson<SonarrSeries[]>(
`${cfg.url}/api/v3/series/lookup?term=tvdb%3A${tvdbId}`,
cfg,
"lookup/tvdb",
);
// Only trust an exact tvdbId match. Falling back to lookup[0] silently
// attaches whatever Sonarr returned first (often a fuzzy title match) and
// caused shows to be labelled with completely unrelated languages.
const fromLookup = lookup?.find((s) => String(s.tvdbId ?? "") === String(tvdbId));
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;
}