3341ceed14
Build and Push Docker Image / build (push) Successful in 1m13s
upsertJellyfinItem no longer runs analyzeItem or creates stream_decisions. it inserts a minimal review_plans stub (pending, unsorted). all analysis happens in processInbox. this means after scan, ALL items land in the inbox — the "needs action" count equals the inbox count until processing classifies them. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
149 lines
4.9 KiB
TypeScript
149 lines
4.9 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 { RescanConfig } from "../rescan";
|
|
import { upsertJellyfinItem } from "../rescan";
|
|
|
|
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 = {};
|
|
|
|
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 — 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", 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");
|
|
});
|
|
});
|
|
|
|
describe("upsertJellyfinItem — scan language source", () => {
|
|
test("scan stores jellyfin guess, never sonarr/radarr source", async () => {
|
|
const db = makeDb();
|
|
const episode = episodeWithSeriesTvdb();
|
|
await upsertJellyfinItem(db, episode, BASE_CFG);
|
|
const row = getItem(db, "jf-ep1");
|
|
expect(row.orig_lang_source).not.toBe("sonarr");
|
|
expect(row.orig_lang_source).not.toBe("radarr");
|
|
});
|
|
});
|