scan: validate arr URLs upfront, cache library once per scan
All checks were successful
Build and Push Docker Image / build (push) Successful in 30s
All checks were successful
Build and Push Docker Image / build (push) Successful in 30s
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.
This commit is contained in:
@@ -4,8 +4,16 @@ import { getAllConfig, getConfig, getDb, setConfig } from "../db/index";
|
|||||||
import { log, error as logError, warn } from "../lib/log";
|
import { log, error as logError, warn } from "../lib/log";
|
||||||
import { analyzeItem } from "../services/analyzer";
|
import { analyzeItem } from "../services/analyzer";
|
||||||
import { extractOriginalLanguage, getAllItems, getDevItems, mapStream, normalizeLanguage } from "../services/jellyfin";
|
import { extractOriginalLanguage, getAllItems, getDevItems, mapStream, normalizeLanguage } from "../services/jellyfin";
|
||||||
import { getOriginalLanguage as radarrLang } from "../services/radarr";
|
import {
|
||||||
import { getOriginalLanguage as sonarrLang } from "../services/sonarr";
|
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";
|
import type { MediaStream } from "../types";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -152,13 +160,31 @@ async function runScan(limit: number | null = null): Promise<void> {
|
|||||||
const jellyfinCfg = { url: cfg.jellyfin_url, apiKey: cfg.jellyfin_api_key, userId: cfg.jellyfin_user_id };
|
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 subtitleLanguages = parseLanguageList(cfg.subtitle_languages ?? null, ["eng", "deu", "spa"]);
|
||||||
const audioLanguages = parseLanguageList(cfg.audio_languages ?? null, []);
|
const audioLanguages = parseLanguageList(cfg.audio_languages ?? null, []);
|
||||||
const radarrEnabled = cfg.radarr_enabled === "1";
|
const radarrCfg = { url: cfg.radarr_url, apiKey: cfg.radarr_api_key };
|
||||||
const sonarrEnabled = cfg.sonarr_enabled === "1";
|
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(
|
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 radarrMisses = 0;
|
||||||
let radarrHits = 0;
|
let radarrHits = 0;
|
||||||
@@ -250,14 +276,15 @@ async function runScan(limit: number | null = null): Promise<void> {
|
|||||||
let needsReview = origLang ? 0 : 1;
|
let needsReview = origLang ? 0 : 1;
|
||||||
let authoritative = false; // set when Radarr/Sonarr answers
|
let authoritative = false; // set when Radarr/Sonarr answers
|
||||||
|
|
||||||
if (jellyfinItem.Type === "Movie" && radarrEnabled) {
|
if (jellyfinItem.Type === "Movie" && radarrEnabled && radarrLibrary) {
|
||||||
if (!tmdbId && !imdbId) {
|
if (!tmdbId && !imdbId) {
|
||||||
missingProviderIds++;
|
missingProviderIds++;
|
||||||
warn(`No tmdb/imdb id on '${jellyfinItem.Name}' — Radarr lookup skipped`);
|
warn(`No tmdb/imdb id on '${jellyfinItem.Name}' — Radarr lookup skipped`);
|
||||||
} else {
|
} else {
|
||||||
const lang = await radarrLang(
|
const lang = await radarrLang(
|
||||||
{ url: cfg.radarr_url, apiKey: cfg.radarr_api_key },
|
radarrCfg,
|
||||||
{ tmdbId: tmdbId ?? undefined, imdbId: imdbId ?? undefined },
|
{ tmdbId: tmdbId ?? undefined, imdbId: imdbId ?? undefined },
|
||||||
|
radarrLibrary,
|
||||||
);
|
);
|
||||||
if (lang) {
|
if (lang) {
|
||||||
radarrHits++;
|
radarrHits++;
|
||||||
@@ -274,12 +301,12 @@ async function runScan(limit: number | null = null): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (jellyfinItem.Type === "Episode" && sonarrEnabled) {
|
if (jellyfinItem.Type === "Episode" && sonarrEnabled && sonarrLibrary) {
|
||||||
if (!tvdbId) {
|
if (!tvdbId) {
|
||||||
missingProviderIds++;
|
missingProviderIds++;
|
||||||
warn(`No tvdb id on '${jellyfinItem.Name}' — Sonarr lookup skipped`);
|
warn(`No tvdb id on '${jellyfinItem.Name}' — Sonarr lookup skipped`);
|
||||||
} else {
|
} else {
|
||||||
const lang = await sonarrLang({ url: cfg.sonarr_url, apiKey: cfg.sonarr_api_key }, tvdbId);
|
const lang = await sonarrLang(sonarrCfg, tvdbId, sonarrLibrary);
|
||||||
if (lang) {
|
if (lang) {
|
||||||
sonarrHits++;
|
sonarrHits++;
|
||||||
if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1;
|
if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1;
|
||||||
|
|||||||
@@ -10,7 +10,19 @@ function headers(apiKey: string): Record<string, string> {
|
|||||||
return { "X-Api-Key": apiKey };
|
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 }> {
|
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 {
|
try {
|
||||||
const res = await fetch(`${cfg.url}/api/v3/system/status`, {
|
const res = await fetch(`${cfg.url}/api/v3/system/status`, {
|
||||||
headers: headers(cfg.apiKey),
|
headers: headers(cfg.apiKey),
|
||||||
@@ -28,41 +40,68 @@ interface RadarrMovie {
|
|||||||
originalLanguage?: { name: string; nameTranslated?: 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> {
|
async function fetchJson<T>(url: string, cfg: RadarrConfig, context: string): Promise<T | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { headers: headers(cfg.apiKey) });
|
const res = await fetch(url, { headers: headers(cfg.apiKey) });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
warn(`Radarr ${context} → HTTP ${res.status} (${url.replace(cfg.apiKey, "***")})`);
|
warn(`Radarr ${context} → HTTP ${res.status}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logError(`Radarr ${context} failed:`, e);
|
logError(`Radarr ${context} failed:`, (e as Error).message);
|
||||||
return null;
|
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<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(
|
export async function getOriginalLanguage(
|
||||||
cfg: RadarrConfig,
|
cfg: RadarrConfig,
|
||||||
ids: { tmdbId?: string; imdbId?: string },
|
ids: { tmdbId?: string; imdbId?: string },
|
||||||
|
library: RadarrLibrary,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
// 1. Library lookup by tmdbId: only hits movies actively managed in Radarr.
|
// Library hit — no HTTP at all.
|
||||||
if (ids.tmdbId) {
|
if (ids.tmdbId) {
|
||||||
const list = await fetchJson<RadarrMovie[]>(`${cfg.url}/api/v3/movie?tmdbId=${ids.tmdbId}`, cfg, "library/tmdb");
|
const m = library.byTmdbId.get(ids.tmdbId);
|
||||||
const movie = list?.[0];
|
if (m?.originalLanguage) return nameToIso(m.originalLanguage.name);
|
||||||
if (movie?.originalLanguage) return nameToIso(movie.originalLanguage.name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Library lookup by imdbId (sidestep: pull all and filter).
|
|
||||||
if (ids.imdbId) {
|
if (ids.imdbId) {
|
||||||
const list = await fetchJson<(RadarrMovie & { imdbId?: string })[]>(`${cfg.url}/api/v3/movie`, cfg, "library/all");
|
const m = library.byImdbId.get(ids.imdbId);
|
||||||
const movie = list?.find((m) => m.imdbId === ids.imdbId);
|
if (m?.originalLanguage) return nameToIso(m.originalLanguage.name);
|
||||||
if (movie?.originalLanguage) return nameToIso(movie.originalLanguage.name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Metadata lookup — asks Radarr to consult TMDB for movies NOT in the
|
// Missing from library — one metadata-lookup call. Prefer tmdbId since
|
||||||
// library. This is the fix for the 8-Mile-in-Jellyfin-but-not-in-Radarr case.
|
// it goes straight to TMDB; fall back to imdb if that's all we have.
|
||||||
|
if (!isUsable(cfg)) return null;
|
||||||
if (ids.tmdbId) {
|
if (ids.tmdbId) {
|
||||||
const result = await fetchJson<RadarrMovie>(
|
const result = await fetchJson<RadarrMovie>(
|
||||||
`${cfg.url}/api/v3/movie/lookup/tmdb?tmdbId=${ids.tmdbId}`,
|
`${cfg.url}/api/v3/movie/lookup/tmdb?tmdbId=${ids.tmdbId}`,
|
||||||
@@ -70,9 +109,7 @@ export async function getOriginalLanguage(
|
|||||||
"lookup/tmdb",
|
"lookup/tmdb",
|
||||||
);
|
);
|
||||||
if (result?.originalLanguage) return nameToIso(result.originalLanguage.name);
|
if (result?.originalLanguage) return nameToIso(result.originalLanguage.name);
|
||||||
}
|
} else if (ids.imdbId) {
|
||||||
|
|
||||||
if (ids.imdbId) {
|
|
||||||
const result = await fetchJson<RadarrMovie>(
|
const result = await fetchJson<RadarrMovie>(
|
||||||
`${cfg.url}/api/v3/movie/lookup/imdb?imdbId=${ids.imdbId}`,
|
`${cfg.url}/api/v3/movie/lookup/imdb?imdbId=${ids.imdbId}`,
|
||||||
cfg,
|
cfg,
|
||||||
@@ -137,16 +174,10 @@ const NAME_TO_639_2: Record<string, string> = {
|
|||||||
"portuguese (brazil)": "por",
|
"portuguese (brazil)": "por",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Map a Radarr language name to ISO 639-2 or null. Logs unmapped names. */
|
|
||||||
function nameToIso(name: string): string | null {
|
function nameToIso(name: string): string | null {
|
||||||
const key = name.toLowerCase().trim();
|
const key = name.toLowerCase().trim();
|
||||||
if (NAME_TO_639_2[key]) return NAME_TO_639_2[key];
|
if (NAME_TO_639_2[key]) return NAME_TO_639_2[key];
|
||||||
// If Radarr ever returns a bare code (e.g., "en", "eng"), normalizeLanguage
|
if (key.length === 2 || key.length === 3) return normalizeLanguage(key);
|
||||||
// will pass it through. Anything else is unknown — return null instead of
|
warn(`Radarr language name not recognised: '${name}'.`);
|
||||||
// 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,19 @@ function headers(apiKey: string): Record<string, string> {
|
|||||||
return { "X-Api-Key": apiKey };
|
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 }> {
|
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 {
|
try {
|
||||||
const res = await fetch(`${cfg.url}/api/v3/system/status`, {
|
const res = await fetch(`${cfg.url}/api/v3/system/status`, {
|
||||||
headers: headers(cfg.apiKey),
|
headers: headers(cfg.apiKey),
|
||||||
@@ -27,28 +39,47 @@ interface SonarrSeries {
|
|||||||
originalLanguage?: { name: string };
|
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> {
|
async function fetchJson<T>(url: string, cfg: SonarrConfig, context: string): Promise<T | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { headers: headers(cfg.apiKey) });
|
const res = await fetch(url, { headers: headers(cfg.apiKey) });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
warn(`Sonarr ${context} → HTTP ${res.status} (${url.replace(cfg.apiKey, "***")})`);
|
warn(`Sonarr ${context} → HTTP ${res.status}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logError(`Sonarr ${context} failed:`, e);
|
logError(`Sonarr ${context} failed:`, (e as Error).message);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns ISO 639-2 original language for a series or null. */
|
export async function loadLibrary(cfg: SonarrConfig): Promise<SonarrLibrary> {
|
||||||
export async function getOriginalLanguage(cfg: SonarrConfig, tvdbId: string): Promise<string | null> {
|
const empty: SonarrLibrary = { byTvdbId: new Map() };
|
||||||
// 1. Library: only hits series actively managed in Sonarr.
|
if (!isUsable(cfg)) return empty;
|
||||||
const list = await fetchJson<SonarrSeries[]>(`${cfg.url}/api/v3/series?tvdbId=${tvdbId}`, cfg, "library/tvdb");
|
const list = await fetchJson<SonarrSeries[]>(`${cfg.url}/api/v3/series`, cfg, "library/all");
|
||||||
const series = list?.[0];
|
if (!list) return empty;
|
||||||
if (series?.originalLanguage) return nameToIso(series.originalLanguage.name);
|
const byTvdbId = new Map<string, SonarrSeries>();
|
||||||
|
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<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[]>(
|
const lookup = await fetchJson<SonarrSeries[]>(
|
||||||
`${cfg.url}/api/v3/series/lookup?term=tvdb%3A${tvdbId}`,
|
`${cfg.url}/api/v3/series/lookup?term=tvdb%3A${tvdbId}`,
|
||||||
cfg,
|
cfg,
|
||||||
|
|||||||
Reference in New Issue
Block a user