Files
netfelix-audio-fix/server/services/jellyfin.ts
Felix Förtsch 6fcaeca82c
Some checks failed
Build and Push Docker Image / build (push) Failing after 16s
write canonical iso3 language metadata, tighten is_noop, store full jellyfin data
ffmpeg now writes -metadata:s:a:i language=<iso3> on every kept audio track so
files end up with canonical 3-letter tags (en → eng, ger → deu, null → und).
analyzer passes stream.profile (not title) to transcodeTarget so lossless
dts-hd ma in mkv correctly targets flac. is_noop also checks og-is-default and
canonical-language so pipeline-would-change-it cases stop showing as done.

normalizeLanguage gains 2→3 mapping, and mapStream no longer normalizes at
ingest so the raw jellyfin tag survives for the canonical check.

per-item scan work runs in a single db.transaction for large sqlite speedups,
extracted into server/services/rescan.ts so execute.ts can reuse it.

on successful job, execute calls jellyfin /Items/{id}/Refresh, waits for
DateLastRefreshed to change, refetches the item, and upserts it through the
same pipeline; plan flips to done iff the fresh streams satisfy is_noop.

schema wiped + rewritten to carry jellyfin_raw, external_raw, profile,
bit_depth, date_last_refreshed, runtime_ticks, original_title, last_executed_at
— so future scans aren't required to stay correct. user must drop data/*.db.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:56:19 +02:00

321 lines
10 KiB
TypeScript

import type { JellyfinItem, JellyfinMediaStream, JellyfinUser, MediaStream } from "../types";
export interface JellyfinConfig {
url: string;
apiKey: string;
/** Optional: when omitted the server-level /Items endpoint is used (requires admin API key). */
userId?: string;
}
/** Build the base items URL: user-scoped when userId is set, server-level otherwise. */
function itemsBaseUrl(cfg: JellyfinConfig): string {
return cfg.userId ? `${cfg.url}/Users/${cfg.userId}/Items` : `${cfg.url}/Items`;
}
const PAGE_SIZE = 200;
function headers(apiKey: string): Record<string, string> {
return {
"X-Emby-Token": apiKey,
"Content-Type": "application/json",
};
}
export async function testConnection(cfg: JellyfinConfig): Promise<{ ok: boolean; error?: string }> {
try {
const res = await fetch(`${cfg.url}/Users`, {
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) };
}
}
export async function getUsers(cfg: Pick<JellyfinConfig, "url" | "apiKey">): Promise<JellyfinUser[]> {
const res = await fetch(`${cfg.url}/Users`, { headers: headers(cfg.apiKey) });
if (!res.ok) throw new Error(`Jellyfin /Users failed: ${res.status}`);
return res.json() as Promise<JellyfinUser[]>;
}
const ITEM_FIELDS = [
"MediaStreams",
"Path",
"ProviderIds",
"OriginalTitle",
"ProductionYear",
"Size",
"Container",
"RunTimeTicks",
"DateLastRefreshed",
].join(",");
export async function* getAllItems(
cfg: JellyfinConfig,
onProgress?: (count: number, total: number) => void,
): AsyncGenerator<JellyfinItem> {
let startIndex = 0;
let total = 0;
do {
const url = new URL(itemsBaseUrl(cfg));
url.searchParams.set("Recursive", "true");
url.searchParams.set("IncludeItemTypes", "Movie,Episode");
url.searchParams.set("Fields", ITEM_FIELDS);
url.searchParams.set("Limit", String(PAGE_SIZE));
url.searchParams.set("StartIndex", String(startIndex));
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
if (!res.ok) throw new Error(`Jellyfin items failed: ${res.status}`);
const body = (await res.json()) as { Items: JellyfinItem[]; TotalRecordCount: number };
total = body.TotalRecordCount;
for (const item of body.Items) {
yield item;
}
startIndex += body.Items.length;
onProgress?.(startIndex, total);
} while (startIndex < total);
}
/**
* Dev mode: yields 50 random movies + all episodes from 10 random series.
* Used instead of getAllItems() when NODE_ENV=development.
*/
export async function* getDevItems(cfg: JellyfinConfig): AsyncGenerator<JellyfinItem> {
// 50 random movies
const movieUrl = new URL(itemsBaseUrl(cfg));
movieUrl.searchParams.set("Recursive", "true");
movieUrl.searchParams.set("IncludeItemTypes", "Movie");
movieUrl.searchParams.set("SortBy", "Random");
movieUrl.searchParams.set("Limit", "50");
movieUrl.searchParams.set("Fields", ITEM_FIELDS);
const movieRes = await fetch(movieUrl.toString(), { headers: headers(cfg.apiKey) });
if (!movieRes.ok)
throw new Error(`Jellyfin movies failed: HTTP ${movieRes.status} — check JELLYFIN_URL and JELLYFIN_API_KEY`);
const movieBody = (await movieRes.json()) as { Items: JellyfinItem[] };
for (const item of movieBody.Items) yield item;
// 10 random series → yield all their episodes
const seriesUrl = new URL(itemsBaseUrl(cfg));
seriesUrl.searchParams.set("Recursive", "true");
seriesUrl.searchParams.set("IncludeItemTypes", "Series");
seriesUrl.searchParams.set("SortBy", "Random");
seriesUrl.searchParams.set("Limit", "10");
const seriesRes = await fetch(seriesUrl.toString(), { headers: headers(cfg.apiKey) });
if (!seriesRes.ok) throw new Error(`Jellyfin series failed: HTTP ${seriesRes.status}`);
const seriesBody = (await seriesRes.json()) as { Items: Array<{ Id: string }> };
for (const series of seriesBody.Items) {
const epUrl = new URL(itemsBaseUrl(cfg));
epUrl.searchParams.set("ParentId", series.Id);
epUrl.searchParams.set("Recursive", "true");
epUrl.searchParams.set("IncludeItemTypes", "Episode");
epUrl.searchParams.set("Fields", ITEM_FIELDS);
const epRes = await fetch(epUrl.toString(), { headers: headers(cfg.apiKey) });
if (epRes.ok) {
const epBody = (await epRes.json()) as { Items: JellyfinItem[] };
for (const ep of epBody.Items) yield ep;
}
}
}
/** Fetch a single Jellyfin item by its ID (for per-file rescan). */
export async function getItem(cfg: JellyfinConfig, jellyfinId: string): Promise<JellyfinItem | null> {
const base = cfg.userId ? `${cfg.url}/Users/${cfg.userId}/Items/${jellyfinId}` : `${cfg.url}/Items/${jellyfinId}`;
const url = new URL(base);
url.searchParams.set("Fields", ITEM_FIELDS);
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
if (!res.ok) return null;
return res.json() as Promise<JellyfinItem>;
}
/**
* Trigger a Jellyfin metadata refresh for a single item and wait until it completes.
* Polls DateLastRefreshed until it changes (or timeout is reached).
*/
export async function refreshItem(cfg: JellyfinConfig, jellyfinId: string, timeoutMs = 15000): Promise<void> {
const itemUrl = `${cfg.url}/Items/${jellyfinId}`;
// 1. Snapshot current DateLastRefreshed
const beforeRes = await fetch(itemUrl, { headers: headers(cfg.apiKey) });
if (!beforeRes.ok) throw new Error(`Jellyfin item fetch failed: HTTP ${beforeRes.status}`);
const before = (await beforeRes.json()) as { DateLastRefreshed?: string };
const beforeDate = before.DateLastRefreshed;
// 2. Trigger refresh (returns 204 immediately; refresh runs async)
const refreshUrl = new URL(`${itemUrl}/Refresh`);
refreshUrl.searchParams.set("MetadataRefreshMode", "FullRefresh");
refreshUrl.searchParams.set("ImageRefreshMode", "None");
refreshUrl.searchParams.set("ReplaceAllMetadata", "false");
refreshUrl.searchParams.set("ReplaceAllImages", "false");
const refreshRes = await fetch(refreshUrl.toString(), { method: "POST", headers: headers(cfg.apiKey) });
if (!refreshRes.ok) throw new Error(`Jellyfin refresh failed: HTTP ${refreshRes.status}`);
// 3. Poll until DateLastRefreshed changes
const start = Date.now();
while (Date.now() - start < timeoutMs) {
await new Promise((r) => setTimeout(r, 1000));
const checkRes = await fetch(itemUrl, { headers: headers(cfg.apiKey) });
if (!checkRes.ok) continue;
const check = (await checkRes.json()) as { DateLastRefreshed?: string };
if (check.DateLastRefreshed && check.DateLastRefreshed !== beforeDate) return;
}
// Timeout reached — proceed anyway (refresh may still complete in background)
}
/** Case-insensitive hints that a track is a dub / commentary, not the original. */
const DUB_TITLE_HINTS = /(dub|dubb|synchro|commentary|director)/i;
/**
* Jellyfin has no real original_language field, so we guess from audio streams.
* This is the notorious "8 Mile got labelled Turkish" heuristic — guard it:
* 1. Prefer IsDefault audio when available (Jellyfin sets this from the file's
* default disposition flag; uploaders usually set it to the original).
* 2. Skip tracks whose title screams "dub" / "commentary".
* 3. Fall back to the first non-dub audio track, then first audio track.
* The caller must still treat any jellyfin-sourced value as unverified — this
* just makes the guess less wrong. The trustworthy answer comes from Radarr/Sonarr.
*/
export function extractOriginalLanguage(item: JellyfinItem): string | null {
if (!item.MediaStreams) return null;
const audio = item.MediaStreams.filter((s) => s.Type === "Audio");
if (audio.length === 0) return null;
const notDub = (s: JellyfinMediaStream) => !s.Title || !DUB_TITLE_HINTS.test(s.Title);
const pick = audio.find((s) => s.IsDefault && notDub(s)) ?? audio.find(notDub) ?? audio[0];
return pick.Language ? normalizeLanguage(pick.Language) : null;
}
/**
* Map a Jellyfin MediaStream to our internal MediaStream shape (sans id/item_id).
*
* NOTE: stores the raw `Language` value from Jellyfin (e.g. "en", "eng", "ger",
* null). We intentionally do NOT normalize here because `is_noop` compares
* raw → normalized to decide whether the pipeline should rewrite the tag to
* canonical iso3. Callers that compare languages must use normalizeLanguage().
*/
export function mapStream(s: JellyfinMediaStream): Omit<MediaStream, "id" | "item_id"> {
return {
stream_index: s.Index,
type: s.Type as MediaStream["type"],
codec: s.Codec ?? null,
profile: s.Profile ?? null,
language: s.Language ?? null,
language_display: s.DisplayLanguage ?? null,
title: s.Title ?? null,
is_default: s.IsDefault ? 1 : 0,
is_forced: s.IsForced ? 1 : 0,
is_hearing_impaired: s.IsHearingImpaired ? 1 : 0,
channels: s.Channels ?? null,
channel_layout: s.ChannelLayout ?? null,
bit_rate: s.BitRate ?? null,
sample_rate: s.SampleRate ?? null,
bit_depth: s.BitDepth ?? null,
};
}
// ISO 639-1 (2-letter) → ISO 639-2/B (3-letter) canonical form.
// Used by normalizeLanguage so "en" and "eng" both resolve to "eng" and
// the canonical-language check can flag files whose tags are still 2-letter.
const ISO_1_TO_2: Record<string, string> = {
en: "eng",
de: "deu",
es: "spa",
fr: "fra",
it: "ita",
pt: "por",
ja: "jpn",
ko: "kor",
zh: "zho",
ar: "ara",
ru: "rus",
nl: "nld",
sv: "swe",
no: "nor",
da: "dan",
fi: "fin",
pl: "pol",
tr: "tur",
th: "tha",
hi: "hin",
hu: "hun",
cs: "ces",
ro: "ron",
el: "ell",
he: "heb",
fa: "fas",
uk: "ukr",
id: "ind",
ca: "cat",
nb: "nob",
nn: "nno",
is: "isl",
hr: "hrv",
sk: "slk",
bg: "bul",
sr: "srp",
sl: "slv",
lv: "lav",
lt: "lit",
et: "est",
vi: "vie",
ms: "msa",
ta: "tam",
te: "tel",
};
// ISO 639-2/T → ISO 639-2/B normalization + common aliases
const LANG_ALIASES: Record<string, string> = {
// German: both /T (deu) and /B (ger) → deu
ger: "deu",
// Chinese
chi: "zho",
// French
fre: "fra",
// Dutch
dut: "nld",
// Modern Greek
gre: "ell",
// Hebrew
heb: "heb",
// Farsi
per: "fas",
// Romanian
rum: "ron",
// Malay
may: "msa",
// Tibetan
tib: "bod",
// Burmese
bur: "mya",
// Czech
cze: "ces",
// Slovak
slo: "slk",
// Georgian
geo: "kat",
// Icelandic
ice: "isl",
// Armenian
arm: "hye",
// Basque
baq: "eus",
// Albanian
alb: "sqi",
// Macedonian
mac: "mkd",
// Welsh
wel: "cym",
};
export function normalizeLanguage(lang: string): string {
const lower = lang.toLowerCase().trim();
if (ISO_1_TO_2[lower]) return ISO_1_TO_2[lower];
return LANG_ALIASES[lower] ?? lower;
}