c1baf3e476
Build and Push Docker Image / build (push) Successful in 3m24s
escape hatch for items the audio pipeline can't usefully fix — e.g. a
release whose only audio track is English commentary and needs to be
purged so *arr can find a better one. two buttons on the audio detail
page:
🗑 Delete file — unlinks the file and drops our DB rows
(cascades streams, plans, decisions, jobs)
🗑 Delete & refetch — same, then asks Radarr/Sonarr to rescan
(to notice the deleted file) and trigger
an indexer search for a replacement
backend: POST /api/review/:id/delete { refetch }. the refetch step is
best-effort and its result ships under `refetch` on the response so the
UI can surface partial wins — file deleted + db clean even if Radarr
doesn't have the movie. helpers live on the existing *arr service
modules (triggerMovieRefetch, triggerEpisodeRefetch) and do the command
lookups + POST /api/v3/command calls themselves.
UI uses native confirm dialogs showing the file path. on success,
navigates back to /pipeline since the detail page points at a row that
no longer exists.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
229 lines
6.9 KiB
TypeScript
229 lines
6.9 KiB
TypeScript
import { error as logError, warn } from "../lib/log";
|
|
import { normalizeLanguage } from "./jellyfin";
|
|
|
|
export interface RadarrConfig {
|
|
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: 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 }> {
|
|
if (!isUsable(cfg)) return { ok: false, error: "Radarr 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 RadarrMovie {
|
|
id?: number;
|
|
tmdbId?: number;
|
|
imdbId?: 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> {
|
|
try {
|
|
const res = await fetch(url, { headers: headers(cfg.apiKey) });
|
|
if (!res.ok) {
|
|
warn(`Radarr ${context} → HTTP ${res.status}`);
|
|
return null;
|
|
}
|
|
return (await res.json()) as T;
|
|
} catch (e) {
|
|
logError(`Radarr ${context} failed:`, (e as Error).message);
|
|
return 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(
|
|
cfg: RadarrConfig,
|
|
ids: { tmdbId?: string; imdbId?: string },
|
|
library: RadarrLibrary,
|
|
): Promise<string | null> {
|
|
// Library hit — no HTTP at all.
|
|
if (ids.tmdbId) {
|
|
const m = library.byTmdbId.get(ids.tmdbId);
|
|
if (m?.originalLanguage) return nameToIso(m.originalLanguage.name);
|
|
}
|
|
if (ids.imdbId) {
|
|
const m = library.byImdbId.get(ids.imdbId);
|
|
if (m?.originalLanguage) return nameToIso(m.originalLanguage.name);
|
|
}
|
|
|
|
// Missing from library — one metadata-lookup call. Prefer tmdbId since
|
|
// it goes straight to TMDB; fall back to imdb if that's all we have.
|
|
if (!isUsable(cfg)) return null;
|
|
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);
|
|
} else 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.
|
|
// Map them to ISO 639-2 codes.
|
|
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",
|
|
"brazilian portuguese": "por",
|
|
"portuguese (brazil)": "por",
|
|
};
|
|
|
|
/**
|
|
* Kick Radarr to rescan a movie (pick up that we deleted the file) and then
|
|
* trigger an indexer search so it grabs a replacement. Best-effort: returns
|
|
* a short status so the caller can surface "refetch queued" vs "not found
|
|
* in Radarr" in the API response. No throws.
|
|
*/
|
|
export async function triggerMovieRefetch(
|
|
cfg: RadarrConfig,
|
|
ids: { tmdbId?: string | null; imdbId?: string | null },
|
|
): Promise<{ ok: boolean; movieId?: number; error?: string }> {
|
|
if (!isUsable(cfg)) return { ok: false, error: "Radarr not configured" };
|
|
|
|
// GET /api/v3/movie?tmdbId=X (or imdbId=Y) returns only the matching
|
|
// movie — we need its internal id for the command endpoints below.
|
|
const query = ids.tmdbId
|
|
? `tmdbId=${encodeURIComponent(ids.tmdbId)}`
|
|
: ids.imdbId
|
|
? `imdbId=${encodeURIComponent(ids.imdbId)}`
|
|
: null;
|
|
if (!query) return { ok: false, error: "movie has no tmdb/imdb id" };
|
|
|
|
const matches = await fetchJson<RadarrMovie[]>(`${cfg.url}/api/v3/movie?${query}`, cfg, `movie?${query}`);
|
|
if (!matches || matches.length === 0) return { ok: false, error: "movie not tracked by Radarr" };
|
|
const movieId = matches[0]?.id;
|
|
if (movieId == null) return { ok: false, error: "Radarr movie missing id field" };
|
|
|
|
try {
|
|
await postCommand(cfg, { name: "RescanMovie", movieId });
|
|
await postCommand(cfg, { name: "MoviesSearch", movieIds: [movieId] });
|
|
return { ok: true, movieId };
|
|
} catch (e) {
|
|
return { ok: false, movieId, error: String(e) };
|
|
}
|
|
}
|
|
|
|
async function postCommand(cfg: RadarrConfig, 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(`Radarr language name not recognised: '${name}'.`);
|
|
return null;
|
|
}
|