All checks were successful
Build and Push Docker Image / build (push) Successful in 1m11s
user reported ad astra got the double checkmark instantly after
transcode — correct, and correct to flag: the post-execute
verifyDesiredState ran ffprobe on the file we had just written, so it
tautologically matched the plan every time. not a second opinion.
replaced the flow with the semantics we actually wanted:
1. refreshItem now returns { refreshed: boolean } — true when jellyfin's
DateLastRefreshed actually advanced within the timeout, false when it
didn't. callers can tell 'jellyfin really re-probed' apart from
'we timed out waiting'.
2. handOffToJellyfin post-job: refresh → (only if refreshed=true) fetch
fresh streams → upsertJellyfinItem(source='webhook'). the rescan SQL
sets verified=1 exactly when the fresh analysis sees is_noop=1, so
✓✓ now means 'jellyfin independently re-probed the file we wrote
and agrees it matches the plan'. if jellyfin sees a drifted layout
the plan flips back to pending so the user notices instead of the
job silently rubber-stamping a bad output.
3. dropped the post-execute ffprobe block. the preflight-skipped branch
no longer self-awards verified=1 either; it now does the same hand-
off so jellyfin's re-probe drives the ✓✓ in that branch too.
refreshItem's other two callers (review /rescan, subtitles /rescan)
ignore the return value — their semantics haven't changed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
334 lines
11 KiB
TypeScript
334 lines
11 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).
|
|
*/
|
|
/**
|
|
* Trigger a Jellyfin metadata refresh and poll until the item's
|
|
* `DateLastRefreshed` advances. Returns true when the probe actually ran;
|
|
* false on timeout (caller decides whether to trust the item's current
|
|
* metadata or treat it as unverified — verification paths should NOT
|
|
* proceed on false, since a stale snapshot would give a bogus verdict).
|
|
*/
|
|
export async function refreshItem(
|
|
cfg: JellyfinConfig,
|
|
jellyfinId: string,
|
|
timeoutMs = 15000,
|
|
): Promise<{ refreshed: boolean }> {
|
|
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 { refreshed: true };
|
|
}
|
|
}
|
|
return { refreshed: false };
|
|
}
|
|
|
|
/** 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;
|
|
}
|