remove jellyfin, mqtt, webhook services, fix tests, add schema migrations
- delete server/services/jellyfin.ts, webhook.ts, mqtt.ts and their tests - strip jellyfin/mqtt imports and startup calls from index.tsx and settings.ts - remove /jellyfin, /mqtt, /mqtt/status, /mqtt/test, /jellyfin/webhook-plugin endpoints from settings router - clean ENV_MAP and isEnvConfigured of jellyfin/mqtt keys - add db/index.ts migrations for series_key, duration_seconds, scan_status, scan_error, last_scanned_at (new columns absent on older dev DBs) - move idx_media_items_series_key out of SCHEMA into migrate() so it runs after the column is added - fix all test fixtures: drop jellyfin_id/series_jellyfin_id column refs, update MediaItem/MediaStream object literals to match current types Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,6 @@ function stream(o: StreamOverride): MediaStream {
|
||||
codec: null,
|
||||
profile: null,
|
||||
language: null,
|
||||
language_display: null,
|
||||
title: null,
|
||||
is_default: 0,
|
||||
is_forced: 0,
|
||||
@@ -514,7 +513,7 @@ describe("analyzeItem — auto_class classification", () => {
|
||||
test("orig_lang_source=jellyfin is not authoritative → manual", () => {
|
||||
const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "eac3", language: "eng" })];
|
||||
const result = analyzeItem(
|
||||
{ ...ITEM_DEFAULTS, original_language: "eng", orig_lang_source: "jellyfin", needs_review: 0 },
|
||||
{ ...ITEM_DEFAULTS, original_language: "eng", orig_lang_source: "probe", needs_review: 0 },
|
||||
streams,
|
||||
{ audioLanguages: [] },
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ function stream(o: Partial<MediaStream> & Pick<MediaStream, "id" | "type" | "str
|
||||
codec: null,
|
||||
profile: null,
|
||||
language: null,
|
||||
language_display: null,
|
||||
title: null,
|
||||
is_default: 0,
|
||||
is_forced: 0,
|
||||
@@ -36,28 +35,23 @@ function decision(o: Partial<StreamDecision> & Pick<StreamDecision, "stream_id"
|
||||
|
||||
const ITEM: MediaItem = {
|
||||
id: 1,
|
||||
jellyfin_id: "x",
|
||||
type: "Movie",
|
||||
name: "Test",
|
||||
original_title: null,
|
||||
series_name: null,
|
||||
series_jellyfin_id: null,
|
||||
series_key: null,
|
||||
season_number: null,
|
||||
episode_number: null,
|
||||
year: null,
|
||||
file_path: "/movies/Test.mkv",
|
||||
file_size: null,
|
||||
container: "mkv",
|
||||
runtime_ticks: null,
|
||||
date_last_refreshed: null,
|
||||
duration_seconds: null,
|
||||
original_language: "eng",
|
||||
orig_lang_source: "jellyfin",
|
||||
orig_lang_source: "probe",
|
||||
needs_review: 0,
|
||||
imdb_id: null,
|
||||
tmdb_id: null,
|
||||
tvdb_id: null,
|
||||
jellyfin_raw: null,
|
||||
external_raw: null,
|
||||
scan_status: "scanned",
|
||||
scan_error: null,
|
||||
last_scanned_at: null,
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { JellyfinItem, JellyfinMediaStream } from "../../types";
|
||||
import { extractOriginalLanguage } from "../jellyfin";
|
||||
|
||||
function audio(o: Partial<JellyfinMediaStream>): JellyfinMediaStream {
|
||||
return { Type: "Audio", Index: 0, ...o };
|
||||
}
|
||||
|
||||
function item(streams: JellyfinMediaStream[]): JellyfinItem {
|
||||
return { Id: "x", Type: "Movie", Name: "Test", MediaStreams: streams };
|
||||
}
|
||||
|
||||
describe("extractOriginalLanguage — Jellyfin heuristic", () => {
|
||||
test("returns null when there are no audio streams", () => {
|
||||
expect(extractOriginalLanguage(item([{ Type: "Video", Index: 0 }]))).toBe(null);
|
||||
});
|
||||
|
||||
test("uses the only audio track when there is just one", () => {
|
||||
expect(extractOriginalLanguage(item([audio({ Language: "eng" })]))).toBe("eng");
|
||||
});
|
||||
|
||||
test("prefers the IsDefault audio track over position", () => {
|
||||
// 8 Mile regression: Turkish dub first, English default further down.
|
||||
// Old heuristic took the first track and labelled the movie Turkish.
|
||||
const streams = [audio({ Index: 0, Language: "tur" }), audio({ Index: 1, Language: "eng", IsDefault: true })];
|
||||
expect(extractOriginalLanguage(item(streams))).toBe("eng");
|
||||
});
|
||||
|
||||
test("skips a dub even when it is the default", () => {
|
||||
const streams = [
|
||||
audio({ Index: 0, Language: "tur", IsDefault: true, Title: "Turkish Dub" }),
|
||||
audio({ Index: 1, Language: "eng" }),
|
||||
];
|
||||
expect(extractOriginalLanguage(item(streams))).toBe("eng");
|
||||
});
|
||||
|
||||
test("falls back to first audio track when every track looks like a dub", () => {
|
||||
const streams = [
|
||||
audio({ Index: 0, Language: "tur", Title: "Turkish Dub" }),
|
||||
audio({ Index: 1, Language: "deu", Title: "German Dub" }),
|
||||
];
|
||||
// No good candidate — returns the first audio so there's *some* guess,
|
||||
// but scan.ts is responsible for marking this needs_review.
|
||||
expect(extractOriginalLanguage(item(streams))).toBe("tur");
|
||||
});
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { SCHEMA } from "../../db/schema";
|
||||
import type { JellyfinItem } from "../../types";
|
||||
import type { JellyfinConfig } from "../jellyfin";
|
||||
import type { RescanConfig } from "../rescan";
|
||||
import { _resetDedupe, processWebhookEvent } from "../webhook";
|
||||
|
||||
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 JF: JellyfinConfig = { url: "http://jf", apiKey: "k" };
|
||||
const RESCAN_CFG: RescanConfig = {};
|
||||
|
||||
function fakeItem(over: Partial<JellyfinItem> = {}): JellyfinItem {
|
||||
return {
|
||||
Id: "jf-1",
|
||||
Type: "Movie",
|
||||
Name: "Test Movie",
|
||||
Path: "/movies/Test.mkv",
|
||||
Container: "mkv",
|
||||
MediaStreams: [
|
||||
{ Type: "Video", Index: 0, Codec: "h264" },
|
||||
{ Type: "Audio", Index: 1, Codec: "aac", Language: "eng", IsDefault: true },
|
||||
],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("processWebhookEvent — acceptance", () => {
|
||||
beforeEach(() => _resetDedupe());
|
||||
afterEach(() => _resetDedupe());
|
||||
|
||||
test("rejects playback-related NotificationTypes (the plugin publishes many, we only want ItemAdded)", async () => {
|
||||
const db = makeDb();
|
||||
for (const nt of ["PlaybackStart", "PlaybackProgress", "UserDataSaved", "ItemUpdated"]) {
|
||||
const res = await processWebhookEvent(
|
||||
{ NotificationType: nt, ItemId: "jf-1", ItemType: "Movie" },
|
||||
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem() },
|
||||
);
|
||||
expect(res.accepted).toBe(false);
|
||||
expect(res.reason).toContain("NotificationType");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects non-Movie/Episode types", async () => {
|
||||
const db = makeDb();
|
||||
const res = await processWebhookEvent(
|
||||
{ NotificationType: "ItemAdded", ItemId: "jf-1", ItemType: "Trailer" },
|
||||
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem({ Type: "Trailer" }) },
|
||||
);
|
||||
expect(res.accepted).toBe(false);
|
||||
expect(res.reason).toContain("ItemType");
|
||||
});
|
||||
|
||||
test("rejects missing ItemId", async () => {
|
||||
const db = makeDb();
|
||||
const res = await processWebhookEvent(
|
||||
{ NotificationType: "ItemAdded", ItemType: "Movie" },
|
||||
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem() },
|
||||
);
|
||||
expect(res.accepted).toBe(false);
|
||||
expect(res.reason).toContain("ItemId");
|
||||
});
|
||||
|
||||
test("dedupes bursts within 5s and accepts again after", async () => {
|
||||
const db = makeDb();
|
||||
let fakeNow = 1_000_000;
|
||||
const getItemFn = async () => fakeItem();
|
||||
const payload = { NotificationType: "ItemAdded", ItemId: "jf-1", ItemType: "Movie" };
|
||||
|
||||
const first = await processWebhookEvent(payload, {
|
||||
db,
|
||||
jellyfin: JF,
|
||||
rescanCfg: RESCAN_CFG,
|
||||
getItemFn,
|
||||
now: () => fakeNow,
|
||||
});
|
||||
expect(first.accepted).toBe(true);
|
||||
|
||||
fakeNow += 1000;
|
||||
const second = await processWebhookEvent(payload, {
|
||||
db,
|
||||
jellyfin: JF,
|
||||
rescanCfg: RESCAN_CFG,
|
||||
getItemFn,
|
||||
now: () => fakeNow,
|
||||
});
|
||||
expect(second.accepted).toBe(false);
|
||||
expect(second.reason).toBe("deduped");
|
||||
|
||||
fakeNow += 5001;
|
||||
const third = await processWebhookEvent(payload, {
|
||||
db,
|
||||
jellyfin: JF,
|
||||
rescanCfg: RESCAN_CFG,
|
||||
getItemFn,
|
||||
now: () => fakeNow,
|
||||
});
|
||||
expect(third.accepted).toBe(true);
|
||||
});
|
||||
|
||||
test("drops when Jellyfin returns no item", async () => {
|
||||
const db = makeDb();
|
||||
const res = await processWebhookEvent(
|
||||
{ NotificationType: "ItemAdded", ItemId: "jf-missing", ItemType: "Movie" },
|
||||
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => null },
|
||||
);
|
||||
expect(res.accepted).toBe(false);
|
||||
expect(res.reason).toContain("no item");
|
||||
});
|
||||
});
|
||||
|
||||
describe("processWebhookEvent — done-status override", () => {
|
||||
beforeEach(() => _resetDedupe());
|
||||
|
||||
async function runWebhook(db: Database, item: JellyfinItem, cfg: RescanConfig = RESCAN_CFG) {
|
||||
return processWebhookEvent(
|
||||
{ NotificationType: "ItemAdded", ItemId: item.Id, ItemType: item.Type as "Movie" | "Episode" },
|
||||
{ db, jellyfin: JF, rescanCfg: cfg, getItemFn: async () => item },
|
||||
);
|
||||
}
|
||||
|
||||
function planStatusFor(db: Database, jellyfinId: string): string {
|
||||
return (
|
||||
db
|
||||
.prepare("SELECT rp.status FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE mi.jellyfin_id = ?")
|
||||
.get(jellyfinId) as { status: string }
|
||||
).status;
|
||||
}
|
||||
|
||||
test("a webhook reopens a done plan back to pending (will be re-processed)", async () => {
|
||||
const db = makeDb();
|
||||
const fresh = fakeItem();
|
||||
await runWebhook(db, fresh);
|
||||
|
||||
db
|
||||
.prepare(
|
||||
"UPDATE review_plans SET status = 'done' WHERE item_id = (SELECT id FROM media_items WHERE jellyfin_id = ?)",
|
||||
)
|
||||
.run(fresh.Id);
|
||||
|
||||
_resetDedupe();
|
||||
await runWebhook(db, fresh);
|
||||
expect(planStatusFor(db, fresh.Id)).toBe("pending");
|
||||
});
|
||||
|
||||
test("a scan (non-webhook) leaves a done plan as done", async () => {
|
||||
const db = makeDb();
|
||||
const fresh = fakeItem();
|
||||
await runWebhook(db, fresh);
|
||||
|
||||
db
|
||||
.prepare(
|
||||
"UPDATE review_plans SET status = 'done' WHERE item_id = (SELECT id FROM media_items WHERE jellyfin_id = ?)",
|
||||
)
|
||||
.run(fresh.Id);
|
||||
|
||||
// Simulate a rescan (source='scan') via direct upsertJellyfinItem
|
||||
const { upsertJellyfinItem } = await import("../rescan");
|
||||
await upsertJellyfinItem(db, fresh, {}, { source: "scan" });
|
||||
expect(planStatusFor(db, fresh.Id)).toBe("done");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user