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.
425 lines
17 KiB
TypeScript
425 lines
17 KiB
TypeScript
import { Hono } from "hono";
|
|
import { stream } from "hono/streaming";
|
|
import { getAllConfig, getConfig, getDb, setConfig } from "../db/index";
|
|
import { log, error as logError, warn } from "../lib/log";
|
|
import { analyzeItem } from "../services/analyzer";
|
|
import { extractOriginalLanguage, getAllItems, getDevItems, mapStream, normalizeLanguage } from "../services/jellyfin";
|
|
import {
|
|
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";
|
|
|
|
const app = new Hono();
|
|
|
|
// ─── State ────────────────────────────────────────────────────────────────────
|
|
|
|
let scanAbort: AbortController | null = null;
|
|
const scanListeners = new Set<(data: string) => void>();
|
|
|
|
function emitSse(type: string, data: unknown): void {
|
|
const line = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
for (const listener of scanListeners) listener(line);
|
|
}
|
|
|
|
function currentScanLimit(): number | null {
|
|
const v = getConfig("scan_limit");
|
|
return v ? Number(v) : null;
|
|
}
|
|
|
|
function parseLanguageList(raw: string | null, fallback: string[]): string[] {
|
|
if (!raw) return fallback;
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : fallback;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
// ─── Status ───────────────────────────────────────────────────────────────────
|
|
|
|
app.get("/", (c) => {
|
|
const db = getDb();
|
|
const running = getConfig("scan_running") === "1";
|
|
const total = (db.prepare("SELECT COUNT(*) as n FROM media_items").get() as { n: number }).n;
|
|
const scanned = (
|
|
db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'scanned'").get() as { n: number }
|
|
).n;
|
|
const errors = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'error'").get() as { n: number })
|
|
.n;
|
|
const recentItems = db
|
|
.prepare("SELECT name, type, scan_status, file_path FROM media_items ORDER BY last_scanned_at DESC LIMIT 50")
|
|
.all() as { name: string; type: string; scan_status: string; file_path: string }[];
|
|
|
|
return c.json({ running, progress: { scanned, total, errors }, recentItems, scanLimit: currentScanLimit() });
|
|
});
|
|
|
|
// ─── Start ────────────────────────────────────────────────────────────────────
|
|
|
|
app.post("/start", async (c) => {
|
|
const db = getDb();
|
|
// Atomic claim: only succeed if scan_running is not already '1'.
|
|
const claim = db.prepare("UPDATE config SET value = '1' WHERE key = 'scan_running' AND value != '1'").run();
|
|
if (claim.changes === 0) {
|
|
return c.json({ ok: false, error: "Scan already running" }, 409);
|
|
}
|
|
|
|
const body = await c.req.json<{ limit?: number }>().catch(() => ({ limit: undefined }));
|
|
const formLimit = body.limit ?? null;
|
|
const envLimit = process.env.SCAN_LIMIT ? Number(process.env.SCAN_LIMIT) : null;
|
|
const limit = formLimit ?? envLimit ?? null;
|
|
setConfig("scan_limit", limit != null ? String(limit) : "");
|
|
|
|
runScan(limit).catch((err) => {
|
|
logError("Scan failed:", err);
|
|
setConfig("scan_running", "0");
|
|
emitSse("error", { message: String(err) });
|
|
});
|
|
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
// ─── Stop ─────────────────────────────────────────────────────────────────────
|
|
|
|
app.post("/stop", (c) => {
|
|
scanAbort?.abort();
|
|
setConfig("scan_running", "0");
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
// ─── SSE ──────────────────────────────────────────────────────────────────────
|
|
|
|
app.get("/events", (c) => {
|
|
return stream(c, async (s) => {
|
|
c.header("Content-Type", "text/event-stream");
|
|
c.header("Cache-Control", "no-cache");
|
|
c.header("Connection", "keep-alive");
|
|
|
|
const queue: string[] = [];
|
|
let resolve: (() => void) | null = null;
|
|
|
|
const listener = (data: string) => {
|
|
queue.push(data);
|
|
resolve?.();
|
|
};
|
|
|
|
scanListeners.add(listener);
|
|
s.onAbort(() => {
|
|
scanListeners.delete(listener);
|
|
});
|
|
|
|
try {
|
|
while (!s.closed) {
|
|
if (queue.length > 0) {
|
|
await s.write(queue.shift()!);
|
|
} else {
|
|
await new Promise<void>((res) => {
|
|
resolve = res;
|
|
setTimeout(res, 25_000);
|
|
});
|
|
resolve = null;
|
|
if (queue.length === 0) await s.write(": keepalive\n\n");
|
|
}
|
|
}
|
|
} finally {
|
|
scanListeners.delete(listener);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Core scan logic ──────────────────────────────────────────────────────────
|
|
|
|
async function runScan(limit: number | null = null): Promise<void> {
|
|
log(`Scan started${limit ? ` (limit: ${limit})` : ""}`);
|
|
scanAbort = new AbortController();
|
|
const { signal } = scanAbort;
|
|
const isDev = process.env.NODE_ENV === "development";
|
|
const db = getDb();
|
|
|
|
if (isDev) {
|
|
// Order matters only if foreign keys are enforced without CASCADE; we
|
|
// have ON DELETE CASCADE on media_streams/review_plans/stream_decisions/
|
|
// subtitle_files/jobs, so deleting media_items would be enough. List
|
|
// them explicitly for clarity and to survive future schema drift.
|
|
db.prepare("DELETE FROM jobs").run();
|
|
db.prepare("DELETE FROM subtitle_files").run();
|
|
db.prepare("DELETE FROM stream_decisions").run();
|
|
db.prepare("DELETE FROM review_plans").run();
|
|
db.prepare("DELETE FROM media_streams").run();
|
|
db.prepare("DELETE FROM media_items").run();
|
|
}
|
|
|
|
const cfg = getAllConfig();
|
|
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 audioLanguages = parseLanguageList(cfg.audio_languages ?? null, []);
|
|
const radarrCfg = { url: cfg.radarr_url, apiKey: cfg.radarr_api_key };
|
|
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(
|
|
`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 radarrHits = 0;
|
|
let sonarrMisses = 0;
|
|
let sonarrHits = 0;
|
|
let missingProviderIds = 0;
|
|
|
|
let processed = 0;
|
|
let errors = 0;
|
|
let total = 0;
|
|
|
|
const upsertItem = db.prepare(`
|
|
INSERT INTO media_items (
|
|
jellyfin_id, type, name, series_name, series_jellyfin_id,
|
|
season_number, episode_number, year, file_path, file_size, container,
|
|
original_language, orig_lang_source, needs_review,
|
|
imdb_id, tmdb_id, tvdb_id,
|
|
scan_status, last_scanned_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'scanned', datetime('now'))
|
|
ON CONFLICT(jellyfin_id) DO UPDATE SET
|
|
type = excluded.type, name = excluded.name, series_name = excluded.series_name,
|
|
series_jellyfin_id = excluded.series_jellyfin_id, season_number = excluded.season_number,
|
|
episode_number = excluded.episode_number, year = excluded.year, file_path = excluded.file_path,
|
|
file_size = excluded.file_size, container = excluded.container,
|
|
original_language = excluded.original_language, orig_lang_source = excluded.orig_lang_source,
|
|
needs_review = excluded.needs_review, imdb_id = excluded.imdb_id,
|
|
tmdb_id = excluded.tmdb_id, tvdb_id = excluded.tvdb_id,
|
|
scan_status = 'scanned', last_scanned_at = datetime('now')
|
|
`);
|
|
|
|
const deleteStreams = db.prepare("DELETE FROM media_streams WHERE item_id = ?");
|
|
const insertStream = db.prepare(`
|
|
INSERT INTO media_streams (
|
|
item_id, stream_index, type, codec, language, language_display,
|
|
title, is_default, is_forced, is_hearing_impaired,
|
|
channels, channel_layout, bit_rate, sample_rate
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
const upsertPlan = db.prepare(`
|
|
INSERT INTO review_plans (item_id, status, is_noop, confidence, apple_compat, job_type, notes)
|
|
VALUES (?, 'pending', ?, ?, ?, ?, ?)
|
|
ON CONFLICT(item_id) DO UPDATE SET
|
|
status = CASE WHEN review_plans.status IN ('done','error') THEN 'pending' ELSE review_plans.status END,
|
|
is_noop = excluded.is_noop,
|
|
confidence = excluded.confidence,
|
|
apple_compat = excluded.apple_compat,
|
|
job_type = excluded.job_type,
|
|
notes = excluded.notes
|
|
`);
|
|
const upsertDecision = db.prepare(`
|
|
INSERT INTO stream_decisions (plan_id, stream_id, action, target_index, transcode_codec)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(plan_id, stream_id) DO UPDATE SET action = excluded.action, target_index = excluded.target_index, transcode_codec = excluded.transcode_codec
|
|
`);
|
|
const getItemByJellyfinId = db.prepare("SELECT id FROM media_items WHERE jellyfin_id = ?");
|
|
const getPlanByItemId = db.prepare("SELECT id FROM review_plans WHERE item_id = ?");
|
|
const getStreamsByItemId = db.prepare("SELECT * FROM media_streams WHERE item_id = ?");
|
|
|
|
const itemSource = isDev
|
|
? getDevItems(jellyfinCfg)
|
|
: getAllItems(jellyfinCfg, (_fetched, jellyfinTotal) => {
|
|
total = limit != null ? Math.min(limit, jellyfinTotal) : jellyfinTotal;
|
|
});
|
|
for await (const jellyfinItem of itemSource) {
|
|
if (signal.aborted) break;
|
|
if (!isDev && limit != null && processed >= limit) break;
|
|
if (!jellyfinItem.Name || !jellyfinItem.Path) {
|
|
warn(`Skipping item without name/path: id=${jellyfinItem.Id}`);
|
|
continue;
|
|
}
|
|
|
|
processed++;
|
|
emitSse("progress", { scanned: processed, total, current_item: jellyfinItem.Name, errors, running: true });
|
|
|
|
try {
|
|
const providerIds = jellyfinItem.ProviderIds ?? {};
|
|
const imdbId = providerIds.Imdb ?? null;
|
|
const tmdbId = providerIds.Tmdb ?? null;
|
|
const tvdbId = providerIds.Tvdb ?? null;
|
|
|
|
// Jellyfin has no real original_language field; extractOriginalLanguage
|
|
// guesses from the first/default audio stream. That's a DUB MAGNET —
|
|
// files uploaded from non-English regions often have a local dub first,
|
|
// so the "original" comes out as Turkish, German, etc. We record it
|
|
// as a starting point but treat it as unverified.
|
|
const jellyfinGuess = extractOriginalLanguage(jellyfinItem);
|
|
let origLang: string | null = jellyfinGuess;
|
|
let origLangSource = jellyfinGuess ? "jellyfin" : null;
|
|
let needsReview = origLang ? 0 : 1;
|
|
let authoritative = false; // set when Radarr/Sonarr answers
|
|
|
|
if (jellyfinItem.Type === "Movie" && radarrEnabled && radarrLibrary) {
|
|
if (!tmdbId && !imdbId) {
|
|
missingProviderIds++;
|
|
warn(`No tmdb/imdb id on '${jellyfinItem.Name}' — Radarr lookup skipped`);
|
|
} else {
|
|
const lang = await radarrLang(
|
|
radarrCfg,
|
|
{ tmdbId: tmdbId ?? undefined, imdbId: imdbId ?? undefined },
|
|
radarrLibrary,
|
|
);
|
|
if (lang) {
|
|
radarrHits++;
|
|
if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1;
|
|
origLang = lang;
|
|
origLangSource = "radarr";
|
|
authoritative = true;
|
|
} else {
|
|
radarrMisses++;
|
|
warn(
|
|
`Radarr returned no language for '${jellyfinItem.Name}' (tmdb=${tmdbId ?? "-"} imdb=${imdbId ?? "-"}) — falling back to Jellyfin guess`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (jellyfinItem.Type === "Episode" && sonarrEnabled && sonarrLibrary) {
|
|
if (!tvdbId) {
|
|
missingProviderIds++;
|
|
warn(`No tvdb id on '${jellyfinItem.Name}' — Sonarr lookup skipped`);
|
|
} else {
|
|
const lang = await sonarrLang(sonarrCfg, tvdbId, sonarrLibrary);
|
|
if (lang) {
|
|
sonarrHits++;
|
|
if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1;
|
|
origLang = lang;
|
|
origLangSource = "sonarr";
|
|
authoritative = true;
|
|
} else {
|
|
sonarrMisses++;
|
|
warn(`Sonarr returned no language for '${jellyfinItem.Name}' (tvdb=${tvdbId}) — falling back to Jellyfin guess`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// High confidence requires an authoritative source (Radarr/Sonarr) and
|
|
// no conflict. A Jellyfin-only guess is ALWAYS low confidence and gets
|
|
// flagged for review — that's how 8 Mile landed as "Turkish": default
|
|
// audio was a Turkish dub, Radarr wasn't available or didn't have the
|
|
// movie, and the guess got a green 'high' badge it never earned.
|
|
let confidence: "high" | "low" = "low";
|
|
if (origLang && authoritative && !needsReview) {
|
|
confidence = "high";
|
|
} else if (origLang && !authoritative) {
|
|
// Jellyfin guess only — surface it for manual review.
|
|
needsReview = 1;
|
|
}
|
|
|
|
upsertItem.run(
|
|
jellyfinItem.Id,
|
|
jellyfinItem.Type === "Episode" ? "Episode" : "Movie",
|
|
jellyfinItem.Name,
|
|
jellyfinItem.SeriesName ?? null,
|
|
jellyfinItem.SeriesId ?? null,
|
|
jellyfinItem.ParentIndexNumber ?? null,
|
|
jellyfinItem.IndexNumber ?? null,
|
|
jellyfinItem.ProductionYear ?? null,
|
|
jellyfinItem.Path,
|
|
jellyfinItem.Size ?? null,
|
|
jellyfinItem.Container ?? null,
|
|
origLang,
|
|
origLangSource,
|
|
needsReview,
|
|
imdbId,
|
|
tmdbId,
|
|
tvdbId,
|
|
);
|
|
|
|
const itemRow = getItemByJellyfinId.get(jellyfinItem.Id) as { id: number };
|
|
const itemId = itemRow.id;
|
|
|
|
deleteStreams.run(itemId);
|
|
for (const jStream of jellyfinItem.MediaStreams ?? []) {
|
|
if (jStream.IsExternal) continue; // skip external subs — not embedded in container
|
|
const s = mapStream(jStream);
|
|
insertStream.run(
|
|
itemId,
|
|
s.stream_index,
|
|
s.type,
|
|
s.codec,
|
|
s.language,
|
|
s.language_display,
|
|
s.title,
|
|
s.is_default,
|
|
s.is_forced,
|
|
s.is_hearing_impaired,
|
|
s.channels,
|
|
s.channel_layout,
|
|
s.bit_rate,
|
|
s.sample_rate,
|
|
);
|
|
}
|
|
|
|
const streams = getStreamsByItemId.all(itemId) as MediaStream[];
|
|
const analysis = analyzeItem(
|
|
{ original_language: origLang, needs_review: needsReview, container: jellyfinItem.Container ?? null },
|
|
streams,
|
|
{ subtitleLanguages, audioLanguages },
|
|
);
|
|
// Override base confidence with scan-computed value
|
|
const finalConfidence = confidence;
|
|
upsertPlan.run(
|
|
itemId,
|
|
analysis.is_noop ? 1 : 0,
|
|
finalConfidence,
|
|
analysis.apple_compat,
|
|
analysis.job_type,
|
|
analysis.notes.length > 0 ? analysis.notes.join("\n") : null,
|
|
);
|
|
const planRow = getPlanByItemId.get(itemId) as { id: number };
|
|
for (const dec of analysis.decisions)
|
|
upsertDecision.run(planRow.id, dec.stream_id, dec.action, dec.target_index, dec.transcode_codec);
|
|
|
|
emitSse("log", { name: jellyfinItem.Name, type: jellyfinItem.Type, status: "scanned", file: jellyfinItem.Path });
|
|
} catch (err) {
|
|
errors++;
|
|
logError(`Error scanning ${jellyfinItem.Name}:`, err);
|
|
try {
|
|
db
|
|
.prepare("UPDATE media_items SET scan_status = 'error', scan_error = ? WHERE jellyfin_id = ?")
|
|
.run(String(err), jellyfinItem.Id);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
emitSse("log", { name: jellyfinItem.Name, type: jellyfinItem.Type, status: "error", file: jellyfinItem.Path });
|
|
}
|
|
}
|
|
|
|
setConfig("scan_running", "0");
|
|
log(`Scan complete: ${processed} scanned, ${errors} errors`);
|
|
log(
|
|
` language sources: radarr hits=${radarrHits} misses=${radarrMisses}, sonarr hits=${sonarrHits} misses=${sonarrMisses}, no provider id=${missingProviderIds}`,
|
|
);
|
|
emitSse("complete", { scanned: processed, total, errors });
|
|
}
|
|
|
|
export default app;
|