91d8ed67b8
Build and Push Docker Image / build (push) Successful in 2m22s
rescan flagged every item where radarr/sonarr disagreed with jellyfin's audio-track guess as needs_review=1, but the analyzer's authoritativeOg check demands needs_review=0 — so the very items we had an authoritative answer for were the ones dumped into the "Needs decision" bucket. Lost (german dubs first on most files, sonarr authoritatively english) was the perfect worst case: nearly every episode misclassified as manual. trust the authoritative source unconditionally and reset needs_review=0 when it fires, mismatch or not.
224 lines
7.5 KiB
TypeScript
224 lines
7.5 KiB
TypeScript
import { Database } from "bun:sqlite";
|
|
import { describe, expect, test } from "bun:test";
|
|
import { SCHEMA } from "../../db/schema";
|
|
import type { JellyfinItem, MediaItem } from "../../types";
|
|
import type { RadarrLibrary } from "../radarr";
|
|
import type { RescanConfig } from "../rescan";
|
|
import { upsertJellyfinItem } from "../rescan";
|
|
import type { SonarrLibrary } from "../sonarr";
|
|
|
|
function makeDb(): Database {
|
|
const db = new Database(":memory:");
|
|
for (const stmt of SCHEMA.split(";")) {
|
|
const trimmed = stmt.trim();
|
|
if (trimmed) db.run(trimmed);
|
|
}
|
|
return db;
|
|
}
|
|
|
|
const BASE_CFG: RescanConfig = {
|
|
audioLanguages: [],
|
|
radarr: null,
|
|
sonarr: null,
|
|
radarrLibrary: null,
|
|
sonarrLibrary: null,
|
|
};
|
|
|
|
function germanDubbedMovie(over: Partial<JellyfinItem> = {}): JellyfinItem {
|
|
return {
|
|
Id: "jf-m1",
|
|
Type: "Movie",
|
|
Name: "Some Dubbed Movie",
|
|
Path: "/movies/Some.mkv",
|
|
Container: "mkv",
|
|
ProviderIds: { Tmdb: "12345" },
|
|
MediaStreams: [
|
|
{ Type: "Video", Index: 0, Codec: "h264" },
|
|
// German audio flagged default — Jellyfin's guess would return "ger"/"deu".
|
|
{ Type: "Audio", Index: 1, Codec: "aac", Language: "ger", IsDefault: true },
|
|
{ Type: "Audio", Index: 2, Codec: "aac", Language: "eng" },
|
|
],
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function episodeWithSeriesTvdb(over: Partial<JellyfinItem> = {}): JellyfinItem {
|
|
return {
|
|
Id: "jf-ep1",
|
|
Type: "Episode",
|
|
Name: "S02E02",
|
|
Path: "/tv/Show/S02E02.mkv",
|
|
Container: "mkv",
|
|
SeriesName: "Some Show",
|
|
SeriesId: "series-1",
|
|
ParentIndexNumber: 2,
|
|
IndexNumber: 2,
|
|
ProviderIds: { Tvdb: "EPISODE_TVDB_9999" },
|
|
SeriesProviderIds: { Tvdb: "SERIES_TVDB_1234" },
|
|
MediaStreams: [
|
|
{ Type: "Video", Index: 0, Codec: "h264" },
|
|
{ Type: "Audio", Index: 1, Codec: "aac", Language: "ita", IsDefault: true },
|
|
],
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function getItem(db: Database, jellyfinId: string): MediaItem {
|
|
return db.prepare("SELECT * FROM media_items WHERE jellyfin_id = ?").get(jellyfinId) as MediaItem;
|
|
}
|
|
|
|
describe("upsertJellyfinItem — manual override preservation", () => {
|
|
test("preserves orig_lang_source='manual' across rescan (Movie)", async () => {
|
|
const db = makeDb();
|
|
await upsertJellyfinItem(db, germanDubbedMovie(), BASE_CFG);
|
|
|
|
// User pins it to English via /api/review/:id/language.
|
|
db
|
|
.prepare(
|
|
"UPDATE media_items SET original_language='eng', orig_lang_source='manual', needs_review=0 WHERE jellyfin_id=?",
|
|
)
|
|
.run("jf-m1");
|
|
|
|
// Rescan re-runs upsertJellyfinItem with the SAME Jellyfin payload
|
|
// (default audio still German). Without the guard, the ON CONFLICT
|
|
// clause would blast 'eng'/'manual' back to 'ger'/'jellyfin'.
|
|
await upsertJellyfinItem(db, germanDubbedMovie(), BASE_CFG);
|
|
|
|
const row = getItem(db, "jf-m1");
|
|
expect(row.original_language).toBe("eng");
|
|
expect(row.orig_lang_source).toBe("manual");
|
|
expect(row.needs_review).toBe(0);
|
|
});
|
|
|
|
test("preserves orig_lang_source='manual' across rescan (Episode)", async () => {
|
|
const db = makeDb();
|
|
await upsertJellyfinItem(db, episodeWithSeriesTvdb(), BASE_CFG);
|
|
|
|
db
|
|
.prepare(
|
|
"UPDATE media_items SET original_language='eng', orig_lang_source='manual', needs_review=0 WHERE jellyfin_id=?",
|
|
)
|
|
.run("jf-ep1");
|
|
|
|
await upsertJellyfinItem(db, episodeWithSeriesTvdb(), BASE_CFG);
|
|
|
|
const row = getItem(db, "jf-ep1");
|
|
expect(row.original_language).toBe("eng");
|
|
expect(row.orig_lang_source).toBe("manual");
|
|
});
|
|
|
|
test("falls through to jellyfin guess when no manual override exists", async () => {
|
|
const db = makeDb();
|
|
await upsertJellyfinItem(db, germanDubbedMovie(), BASE_CFG);
|
|
|
|
const row = getItem(db, "jf-m1");
|
|
// Default audio is German so the guess lands on the German tag.
|
|
// The raw tag is "ger" which normalizes to "deu" in our store.
|
|
expect(row.orig_lang_source).toBe("jellyfin");
|
|
expect(row.original_language).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("upsertJellyfinItem — authoritative source overrides jellyfin guess", () => {
|
|
function sonarrLibraryWith(seriesTvdb: string, langName: string): SonarrLibrary {
|
|
return {
|
|
byTvdbId: new Map([
|
|
[seriesTvdb, { tvdbId: Number.parseInt(seriesTvdb, 10) || 1234, originalLanguage: { name: langName } }],
|
|
]),
|
|
};
|
|
}
|
|
|
|
function radarrLibraryWith(tmdbId: string, langName: string): RadarrLibrary {
|
|
return {
|
|
byTmdbId: new Map([[tmdbId, { tmdbId: Number.parseInt(tmdbId, 10), originalLanguage: { name: langName } }]]),
|
|
byImdbId: new Map(),
|
|
};
|
|
}
|
|
|
|
test("sonarr corrects jellyfin's german-dub guess → needs_review=0, source=sonarr", async () => {
|
|
const db = makeDb();
|
|
// Episode where Jellyfin's audio-track guess would land on German (first
|
|
// default track), but Sonarr authoritatively says English.
|
|
const episode = episodeWithSeriesTvdb({
|
|
MediaStreams: [
|
|
{ Type: "Video", Index: 0, Codec: "h264" },
|
|
{ Type: "Audio", Index: 1, Codec: "aac", Language: "ger", IsDefault: true },
|
|
{ Type: "Audio", Index: 2, Codec: "aac", Language: "eng" },
|
|
],
|
|
});
|
|
const cfg: RescanConfig = {
|
|
...BASE_CFG,
|
|
sonarr: { url: "http://sonarr.local", apiKey: "k" },
|
|
sonarrLibrary: sonarrLibraryWith("SERIES_TVDB_1234", "English"),
|
|
};
|
|
|
|
await upsertJellyfinItem(db, episode, cfg);
|
|
|
|
const row = getItem(db, "jf-ep1");
|
|
expect(row.original_language).toBe("eng");
|
|
expect(row.orig_lang_source).toBe("sonarr");
|
|
// Without the fix this was 1 (set on jellyfin/sonarr mismatch), which
|
|
// knocked every auto-corrected episode into the "Needs decision" bucket.
|
|
expect(row.needs_review).toBe(0);
|
|
});
|
|
|
|
test("radarr corrects jellyfin's german-dub guess → needs_review=0, source=radarr", async () => {
|
|
const db = makeDb();
|
|
const movie = germanDubbedMovie(); // Tmdb "12345", first audio is German
|
|
const cfg: RescanConfig = {
|
|
...BASE_CFG,
|
|
radarr: { url: "http://radarr.local", apiKey: "k" },
|
|
radarrLibrary: radarrLibraryWith("12345", "English"),
|
|
};
|
|
|
|
await upsertJellyfinItem(db, movie, cfg);
|
|
|
|
const row = getItem(db, "jf-m1");
|
|
expect(row.original_language).toBe("eng");
|
|
expect(row.orig_lang_source).toBe("radarr");
|
|
expect(row.needs_review).toBe(0);
|
|
});
|
|
|
|
test("sonarr authoritative when jellyfin couldn't guess at all → needs_review=0", async () => {
|
|
const db = makeDb();
|
|
const episode = episodeWithSeriesTvdb({ MediaStreams: [] });
|
|
const cfg: RescanConfig = {
|
|
...BASE_CFG,
|
|
sonarr: { url: "http://sonarr.local", apiKey: "k" },
|
|
sonarrLibrary: sonarrLibraryWith("SERIES_TVDB_1234", "English"),
|
|
};
|
|
|
|
await upsertJellyfinItem(db, episode, cfg);
|
|
|
|
const row = getItem(db, "jf-ep1");
|
|
expect(row.original_language).toBe("eng");
|
|
expect(row.orig_lang_source).toBe("sonarr");
|
|
expect(row.needs_review).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("upsertJellyfinItem — episode tvdb_id resolution", () => {
|
|
test("uses SeriesProviderIds.Tvdb for episodes, not the episode-level Tvdb", async () => {
|
|
const db = makeDb();
|
|
await upsertJellyfinItem(db, episodeWithSeriesTvdb(), BASE_CFG);
|
|
const row = getItem(db, "jf-ep1");
|
|
expect(row.tvdb_id).toBe("SERIES_TVDB_1234");
|
|
});
|
|
|
|
test("falls back to ProviderIds.Tvdb when SeriesProviderIds absent (older Jellyfin)", async () => {
|
|
const db = makeDb();
|
|
const legacy = episodeWithSeriesTvdb({ SeriesProviderIds: undefined });
|
|
await upsertJellyfinItem(db, legacy, BASE_CFG);
|
|
const row = getItem(db, "jf-ep1");
|
|
expect(row.tvdb_id).toBe("EPISODE_TVDB_9999");
|
|
});
|
|
|
|
test("movies still use ProviderIds.Tvdb directly", async () => {
|
|
const db = makeDb();
|
|
const movie = germanDubbedMovie({ ProviderIds: { Tvdb: "MOVIE_TVDB_1" } });
|
|
await upsertJellyfinItem(db, movie, BASE_CFG);
|
|
const row = getItem(db, "jf-m1");
|
|
expect(row.tvdb_id).toBe("MOVIE_TVDB_1");
|
|
});
|
|
});
|