225 lines
6.9 KiB
TypeScript
225 lines
6.9 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 {
|
|
id?: number;
|
|
tvdbId?: number;
|
|
title?: string;
|
|
originalLanguage?: { name: string };
|
|
}
|
|
|
|
interface SonarrEpisode {
|
|
id?: number;
|
|
seriesId?: number;
|
|
seasonNumber?: number;
|
|
episodeNumber?: number;
|
|
}
|
|
|
|
/** Pre-loaded Sonarr library indexed for O(1) per-series lookups during a scan. */
|
|
export interface SonarrLibrary {
|
|
byTvdbId: Map<string, SonarrSeries>;
|
|
byTitle: 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(), byTitle: 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>();
|
|
const byTitle = new Map<string, SonarrSeries>();
|
|
for (const s of list) {
|
|
if (s.tvdbId != null) byTvdbId.set(String(s.tvdbId), s);
|
|
const title = normalizeSeriesTitle(s.title);
|
|
if (title) byTitle.set(title, s);
|
|
}
|
|
return { byTvdbId, byTitle };
|
|
}
|
|
|
|
/** 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",
|
|
};
|
|
|
|
/**
|
|
* Kick Sonarr to rescan a series (pick up that we deleted one of its files)
|
|
* and then trigger an indexer search for the specific episode so it grabs a
|
|
* replacement. Best-effort with a short status so the caller can surface
|
|
* "refetch queued" vs "not found in Sonarr" on the API response. No throws.
|
|
*/
|
|
export async function triggerEpisodeRefetch(
|
|
cfg: SonarrConfig,
|
|
args: { tvdbId?: string | null; seasonNumber?: number | null; episodeNumber?: number | null },
|
|
): Promise<{ ok: boolean; seriesId?: number; episodeId?: number; error?: string }> {
|
|
if (!isUsable(cfg)) return { ok: false, error: "Sonarr not configured" };
|
|
if (!args.tvdbId) return { ok: false, error: "episode has no tvdb id" };
|
|
if (args.seasonNumber == null || args.episodeNumber == null)
|
|
return { ok: false, error: "missing season/episode number" };
|
|
|
|
const series = await fetchJson<SonarrSeries[]>(
|
|
`${cfg.url}/api/v3/series?tvdbId=${encodeURIComponent(args.tvdbId)}`,
|
|
cfg,
|
|
`series?tvdbId=${args.tvdbId}`,
|
|
);
|
|
if (!series || series.length === 0) return { ok: false, error: "series not tracked by Sonarr" };
|
|
const seriesId = series[0]?.id;
|
|
if (seriesId == null) return { ok: false, error: "Sonarr series missing id field" };
|
|
|
|
// Need the Sonarr episode.id to pass to EpisodeSearch. Pull every episode
|
|
// for the series and filter — cheaper than `/api/v3/episode?seriesId=X`
|
|
// would be for the caller; for our use (one episode, one search) this
|
|
// single call + linear scan is fine.
|
|
const episodes = await fetchJson<SonarrEpisode[]>(
|
|
`${cfg.url}/api/v3/episode?seriesId=${seriesId}`,
|
|
cfg,
|
|
`episode?seriesId=${seriesId}`,
|
|
);
|
|
const episode = episodes?.find((e) => e.seasonNumber === args.seasonNumber && e.episodeNumber === args.episodeNumber);
|
|
if (!episode?.id) return { ok: false, seriesId, error: "matching episode not found in Sonarr" };
|
|
|
|
try {
|
|
await postCommand(cfg, { name: "RescanSeries", seriesId });
|
|
await postCommand(cfg, { name: "EpisodeSearch", episodeIds: [episode.id] });
|
|
return { ok: true, seriesId, episodeId: episode.id };
|
|
} catch (e) {
|
|
return { ok: false, seriesId, episodeId: episode.id, error: String(e) };
|
|
}
|
|
}
|
|
|
|
async function postCommand(cfg: SonarrConfig, body: Record<string, unknown>): Promise<void> {
|
|
const res = await fetch(`${cfg.url}/api/v3/command`, {
|
|
method: "POST",
|
|
headers: { ...headers(cfg.apiKey), "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function normalizeSeriesTitle(title: string | undefined): string | null {
|
|
const normalized = title?.trim().toLowerCase();
|
|
return normalized ? normalized : null;
|
|
}
|