Files
netfelix-audio-fix/server/services/rescan.ts
Felix Förtsch a27e4f4025
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m5s
close the jellyfin ping-pong via mqtt webhook subscriber
after ffmpeg finishes we used to block the queue on a jellyfin refresh
+ re-analyze round-trip. now we just kick jellyfin and return. a new
mqtt subscriber listens for library events from jellyfin's webhook
plugin and re-runs upsertJellyfinItem — flipping plans back to pending
when the on-disk streams still don't match, otherwise confirming done.

- execute.ts: hand-off is fire-and-forget; no more sync re-analyze
- rescan.ts: upsertJellyfinItem takes source: 'scan' | 'webhook'.
  webhook-sourced rescans can reopen terminal 'done' plans when
  is_noop flips back to 0; scan-sourced rescans still treat done as
  terminal (keeps the dup-job fix from a06ab34 intact).
- mqtt.ts: long-lived client, auto-reconnect, status feed for UI badge
- webhook.ts: pure processWebhookEvent(db, deps) handler + 5s dedupe
  map to kill jellyfin's burst re-fires during library scans
- settings: /api/settings/mqtt{,/status,/test} + /api/settings/
  jellyfin/webhook-plugin (checks if the plugin is installed)
- ui: new Settings section with broker form, test button, copy-paste
  setup panel for the Jellyfin plugin template. MQTT status badge on
  the scan page.
2026-04-14 08:26:42 +02:00

282 lines
9.7 KiB
TypeScript

import type { Database } from "bun:sqlite";
import type { JellyfinItem, MediaStream } from "../types";
import { analyzeItem } from "./analyzer";
import { extractOriginalLanguage, mapStream, normalizeLanguage } from "./jellyfin";
import { type RadarrLibrary, getOriginalLanguage as radarrLang } from "./radarr";
import { type SonarrLibrary, getOriginalLanguage as sonarrLang } from "./sonarr";
export interface RescanConfig {
audioLanguages: string[];
radarr: { url: string; apiKey: string } | null;
sonarr: { url: string; apiKey: string } | null;
radarrLibrary: RadarrLibrary | null;
sonarrLibrary: SonarrLibrary | null;
}
export interface RescanResult {
itemId: number;
origLang: string | null;
origLangSource: string | null;
needsReview: number;
confidence: "high" | "low";
isNoop: boolean;
radarrHit: boolean;
radarrMiss: boolean;
sonarrHit: boolean;
sonarrMiss: boolean;
missingProviderId: boolean;
}
/**
* Upsert a single Jellyfin item (metadata + streams + review_plan + decisions)
* in one transaction. Shared by the full scan loop and the post-execute refresh.
*
* Returns the internal item id and a summary of what happened so callers can
* aggregate counters or emit SSE events.
*/
export async function upsertJellyfinItem(
db: Database,
jellyfinItem: JellyfinItem,
cfg: RescanConfig,
opts: { executed?: boolean; source?: "scan" | "webhook" } = {},
): Promise<RescanResult> {
const source = opts.source ?? "scan";
if (!jellyfinItem.Name || !jellyfinItem.Path) {
throw new Error(`Jellyfin item ${jellyfinItem.Id} missing Name or Path`);
}
const itemName: string = jellyfinItem.Name;
const itemPath: string = jellyfinItem.Path;
const providerIds = jellyfinItem.ProviderIds ?? {};
const imdbId = providerIds.Imdb ?? null;
const tmdbId = providerIds.Tmdb ?? null;
const tvdbId = providerIds.Tvdb ?? null;
// See scan.ts for the "8 Mile got labelled Turkish" rationale. Jellyfin's
// first-audio-track guess is an unverified starting point.
const jellyfinGuess = extractOriginalLanguage(jellyfinItem);
let origLang: string | null = jellyfinGuess;
let origLangSource: string | null = jellyfinGuess ? "jellyfin" : null;
let needsReview = origLang ? 0 : 1;
let authoritative = false;
let externalRaw: unknown = null;
const result: RescanResult = {
itemId: -1,
origLang: null,
origLangSource: null,
needsReview: 1,
confidence: "low",
isNoop: false,
radarrHit: false,
radarrMiss: false,
sonarrHit: false,
sonarrMiss: false,
missingProviderId: false,
};
if (jellyfinItem.Type === "Movie" && cfg.radarr && cfg.radarrLibrary) {
if (!tmdbId && !imdbId) {
result.missingProviderId = true;
} else {
const movie = tmdbId ? cfg.radarrLibrary.byTmdbId.get(tmdbId) : undefined;
const movieByImdb = !movie && imdbId ? cfg.radarrLibrary.byImdbId.get(imdbId) : undefined;
externalRaw = movie ?? movieByImdb ?? null;
const lang = await radarrLang(
cfg.radarr,
{ tmdbId: tmdbId ?? undefined, imdbId: imdbId ?? undefined },
cfg.radarrLibrary,
);
if (lang) {
result.radarrHit = true;
if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1;
origLang = lang;
origLangSource = "radarr";
authoritative = true;
} else {
result.radarrMiss = true;
}
}
}
if (jellyfinItem.Type === "Episode" && cfg.sonarr && cfg.sonarrLibrary) {
if (!tvdbId) {
result.missingProviderId = true;
} else {
externalRaw = cfg.sonarrLibrary.byTvdbId.get(tvdbId) ?? null;
const lang = await sonarrLang(cfg.sonarr, tvdbId, cfg.sonarrLibrary);
if (lang) {
result.sonarrHit = true;
if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1;
origLang = lang;
origLangSource = "sonarr";
authoritative = true;
} else {
result.sonarrMiss = true;
}
}
}
let confidence: "high" | "low" = "low";
if (origLang && authoritative && !needsReview) confidence = "high";
else if (origLang && !authoritative) needsReview = 1;
const jellyfinRaw = JSON.stringify(jellyfinItem);
const externalRawJson = externalRaw ? JSON.stringify(externalRaw) : null;
// One transaction per item keeps scan throughput high on SQLite — every
// INSERT/UPDATE would otherwise hit WAL independently.
db.transaction(() => {
const upsertItem = db.prepare(`
INSERT INTO media_items (
jellyfin_id, type, name, original_title, series_name, series_jellyfin_id,
season_number, episode_number, year, file_path, file_size, container,
runtime_ticks, date_last_refreshed,
original_language, orig_lang_source, needs_review,
imdb_id, tmdb_id, tvdb_id,
jellyfin_raw, external_raw,
scan_status, last_scanned_at${opts.executed ? ", last_executed_at" : ""}
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'scanned', datetime('now')${opts.executed ? ", datetime('now')" : ""})
ON CONFLICT(jellyfin_id) DO UPDATE SET
type = excluded.type, name = excluded.name, original_title = excluded.original_title,
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,
runtime_ticks = excluded.runtime_ticks, date_last_refreshed = excluded.date_last_refreshed,
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,
jellyfin_raw = excluded.jellyfin_raw, external_raw = excluded.external_raw,
scan_status = 'scanned', last_scanned_at = datetime('now')
${opts.executed ? ", last_executed_at = datetime('now')" : ""}
`);
upsertItem.run(
jellyfinItem.Id,
jellyfinItem.Type === "Episode" ? "Episode" : "Movie",
itemName,
jellyfinItem.OriginalTitle ?? null,
jellyfinItem.SeriesName ?? null,
jellyfinItem.SeriesId ?? null,
jellyfinItem.ParentIndexNumber ?? null,
jellyfinItem.IndexNumber ?? null,
jellyfinItem.ProductionYear ?? null,
itemPath,
jellyfinItem.Size ?? null,
jellyfinItem.Container ?? null,
jellyfinItem.RunTimeTicks ?? null,
jellyfinItem.DateLastRefreshed ?? null,
origLang,
origLangSource,
needsReview,
imdbId,
tmdbId,
tvdbId,
jellyfinRaw,
externalRawJson,
);
const itemRow = db.prepare("SELECT id FROM media_items WHERE jellyfin_id = ?").get(jellyfinItem.Id) as {
id: number;
};
const itemId = itemRow.id;
result.itemId = itemId;
db.prepare("DELETE FROM media_streams WHERE item_id = ?").run(itemId);
const insertStream = db.prepare(`
INSERT INTO media_streams (
item_id, stream_index, type, codec, profile, language, language_display,
title, is_default, is_forced, is_hearing_impaired,
channels, channel_layout, bit_rate, sample_rate, bit_depth
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const jStream of jellyfinItem.MediaStreams ?? []) {
if (jStream.IsExternal) continue;
const s = mapStream(jStream);
insertStream.run(
itemId,
s.stream_index,
s.type,
s.codec,
s.profile,
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,
s.bit_depth,
);
}
const streams = db.prepare("SELECT * FROM media_streams WHERE item_id = ?").all(itemId) as MediaStream[];
const analysis = analyzeItem(
{ original_language: origLang, needs_review: needsReview, container: jellyfinItem.Container ?? null },
streams,
{ audioLanguages: cfg.audioLanguages },
);
// Status transition rules:
// is_noop=1 → done (all sources)
// done + is_noop=0 + source='webhook' → pending (Jellyfin says the
// on-disk file doesn't match the plan, re-open for review)
// done + is_noop=0 + source='scan' → done (safety net: a plain
// rescan must not reopen plans and risk duplicate jobs; see the
// commit that made done terminal)
// error → pending (retry loop)
// else keep current status
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 excluded.is_noop = 1 THEN 'done'
WHEN review_plans.status = 'done' AND ? = 'webhook' THEN 'pending'
WHEN review_plans.status = 'done' THEN 'done'
WHEN review_plans.status = '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
`)
.run(
itemId,
analysis.is_noop ? 1 : 0,
confidence,
analysis.apple_compat,
analysis.job_type,
analysis.notes.length > 0 ? analysis.notes.join("\n") : null,
source,
);
const planRow = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number };
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
`);
for (const dec of analysis.decisions) {
upsertDecision.run(planRow.id, dec.stream_id, dec.action, dec.target_index, dec.transcode_codec);
}
result.origLang = origLang;
result.origLangSource = origLangSource;
result.needsReview = needsReview;
result.confidence = confidence;
result.isNoop = analysis.is_noop;
})();
return result;
}