Files
netfelix-audio-fix/server/services/rescan.ts
Felix Förtsch a06ab34b98
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m34s
make done plans terminal, add ffprobe preflight to skip already-processed files
root cause of duplicate pipeline entries: rescan.ts flipped done plans
back to pending whenever a post-job jellyfin refresh returned stale
metadata, putting the item back in review and letting a second jobs row
pile up in done. done is now sticky across rescans (error still
re-opens for retries).

second line of defense: before spawning ffmpeg, ffprobe the file and
compare audio count/language/codec order + embedded subtitle count
against the plan. if it already matches, mark the job done with the
reason in jobs.output and skip the spawn. prevents corrupting a
post-processed file with a stale stream-index command.
2026-04-13 21:43:10 +02:00

270 lines
9.1 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 } = {},
): Promise<RescanResult> {
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 },
);
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 = 'done' THEN 'done'
WHEN excluded.is_noop = 1 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,
);
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;
}