686434f5c3
- 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>
179 lines
5.5 KiB
TypeScript
179 lines
5.5 KiB
TypeScript
import { Database } from "bun:sqlite";
|
|
import { describe, expect, test } from "bun:test";
|
|
import { SCHEMA } from "../../db/schema";
|
|
import { buildReviewGroups } from "../review";
|
|
|
|
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;
|
|
}
|
|
|
|
interface SeedOpts {
|
|
id: number;
|
|
type: "Movie" | "Episode";
|
|
name?: string;
|
|
seriesName?: string | null;
|
|
seriesKey?: string | null;
|
|
seasonNumber?: number | null;
|
|
episodeNumber?: number | null;
|
|
autoClass?: "auto" | "auto_heuristic" | "manual" | null;
|
|
sorted?: 0 | 1;
|
|
}
|
|
|
|
function seed(db: Database, opts: SeedOpts) {
|
|
const {
|
|
id,
|
|
type,
|
|
name = `Item ${id}`,
|
|
seriesName = null,
|
|
seriesKey = null,
|
|
seasonNumber = null,
|
|
episodeNumber = null,
|
|
autoClass = "manual",
|
|
sorted = 1,
|
|
} = opts;
|
|
db
|
|
.prepare(
|
|
"INSERT INTO media_items (id, type, name, series_name, series_key, season_number, episode_number, file_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.run(id, type, name, seriesName, seriesKey, seasonNumber, episodeNumber, `/x/${id}.mkv`);
|
|
db
|
|
.prepare(
|
|
"INSERT INTO review_plans (item_id, status, is_noop, auto_class, sorted, apple_compat, job_type, notes) VALUES (?, 'pending', 0, ?, ?, 'direct_play', 'copy', NULL)",
|
|
)
|
|
.run(id, autoClass, sorted);
|
|
}
|
|
|
|
describe("buildReviewGroups", () => {
|
|
test("returns a complete series with every pending episode", () => {
|
|
const db = makeDb();
|
|
for (let i = 1; i <= 30; i++) {
|
|
seed(db, {
|
|
id: i,
|
|
type: "Episode",
|
|
seriesName: "Breaking Bad",
|
|
seriesKey: "bb",
|
|
seasonNumber: 1,
|
|
episodeNumber: i,
|
|
});
|
|
}
|
|
|
|
const { groups, totalItems } = buildReviewGroups(db, { bucket: "review" });
|
|
|
|
expect(groups).toHaveLength(1);
|
|
const series = groups[0];
|
|
expect(series.kind).toBe("series");
|
|
if (series.kind !== "series") throw new Error("expected series");
|
|
expect(series.episodeCount).toBe(30);
|
|
expect(series.seasons).toHaveLength(1);
|
|
expect(series.seasons[0].episodes).toHaveLength(30);
|
|
expect(totalItems).toBe(30);
|
|
});
|
|
|
|
test("buckets episodes by season with null ordered last", () => {
|
|
const db = makeDb();
|
|
for (let ep = 1; ep <= 3; ep++) {
|
|
seed(db, {
|
|
id: ep,
|
|
type: "Episode",
|
|
seriesName: "Lost",
|
|
seriesKey: "lost",
|
|
seasonNumber: 1,
|
|
episodeNumber: ep,
|
|
});
|
|
}
|
|
for (let ep = 1; ep <= 2; ep++) {
|
|
seed(db, {
|
|
id: 10 + ep,
|
|
type: "Episode",
|
|
seriesName: "Lost",
|
|
seriesKey: "lost",
|
|
seasonNumber: 2,
|
|
episodeNumber: ep,
|
|
});
|
|
}
|
|
seed(db, { id: 99, type: "Episode", seriesName: "Lost", seriesKey: "lost", seasonNumber: null });
|
|
|
|
const { groups } = buildReviewGroups(db, { bucket: "review" });
|
|
expect(groups).toHaveLength(1);
|
|
const lost = groups[0];
|
|
if (lost.kind !== "series") throw new Error("expected series");
|
|
expect(lost.seasons.map((s) => s.season)).toEqual([1, 2, null]);
|
|
expect(lost.seasons[0].episodes).toHaveLength(3);
|
|
expect(lost.seasons[1].episodes).toHaveLength(2);
|
|
expect(lost.seasons[2].episodes).toHaveLength(1);
|
|
});
|
|
|
|
test("sorts groups: auto_heuristic (ready) first, then manual, then by name", () => {
|
|
const db = makeDb();
|
|
seed(db, { id: 1, type: "Movie", name: "Zodiac", autoClass: "auto_heuristic" });
|
|
seed(db, { id: 2, type: "Movie", name: "Arrival", autoClass: "manual" });
|
|
seed(db, { id: 3, type: "Movie", name: "Blade Runner", autoClass: "auto_heuristic" });
|
|
|
|
const { groups } = buildReviewGroups(db, { bucket: "review" });
|
|
const names = groups.map((g) => (g.kind === "movie" ? g.item.name : g.seriesName));
|
|
expect(names).toEqual(["Blade Runner", "Zodiac", "Arrival"]);
|
|
});
|
|
|
|
test("series readyCount counts auto_heuristic episodes", () => {
|
|
const db = makeDb();
|
|
seed(db, {
|
|
id: 1,
|
|
type: "Episode",
|
|
seriesName: "Show",
|
|
seriesKey: "s",
|
|
seasonNumber: 1,
|
|
episodeNumber: 1,
|
|
autoClass: "auto_heuristic",
|
|
});
|
|
seed(db, {
|
|
id: 2,
|
|
type: "Episode",
|
|
seriesName: "Show",
|
|
seriesKey: "s",
|
|
seasonNumber: 1,
|
|
episodeNumber: 2,
|
|
autoClass: "manual",
|
|
});
|
|
|
|
const { groups } = buildReviewGroups(db, { bucket: "review" });
|
|
if (groups[0].kind !== "series") throw new Error("expected series");
|
|
expect(groups[0].readyCount).toBe(1);
|
|
});
|
|
|
|
test("excludes plans that are not pending or are is_noop=1", () => {
|
|
const db = makeDb();
|
|
seed(db, { id: 1, type: "Movie", name: "Pending" });
|
|
seed(db, { id: 2, type: "Movie", name: "Approved" });
|
|
db.prepare("UPDATE review_plans SET status = 'approved' WHERE item_id = ?").run(2);
|
|
seed(db, { id: 3, type: "Movie", name: "Noop" });
|
|
db.prepare("UPDATE review_plans SET is_noop = 1 WHERE item_id = ?").run(3);
|
|
|
|
const { groups, totalItems } = buildReviewGroups(db, { bucket: "review" });
|
|
expect(groups).toHaveLength(1);
|
|
expect(totalItems).toBe(1);
|
|
if (groups[0].kind !== "movie") throw new Error("expected movie");
|
|
expect(groups[0].item.name).toBe("Pending");
|
|
});
|
|
|
|
test("bucket=inbox returns sorted=0 plans only", () => {
|
|
const db = makeDb();
|
|
seed(db, { id: 1, type: "Movie", name: "Fresh", autoClass: null, sorted: 0 });
|
|
seed(db, { id: 2, type: "Movie", name: "Old", autoClass: "manual", sorted: 1 });
|
|
|
|
const inbox = buildReviewGroups(db, { bucket: "inbox" });
|
|
expect(inbox.groups).toHaveLength(1);
|
|
if (inbox.groups[0].kind !== "movie") throw new Error("expected movie");
|
|
expect(inbox.groups[0].item.name).toBe("Fresh");
|
|
|
|
const review = buildReviewGroups(db, { bucket: "review" });
|
|
expect(review.groups).toHaveLength(1);
|
|
if (review.groups[0].kind !== "movie") throw new Error("expected movie");
|
|
expect(review.groups[0].item.name).toBe("Old");
|
|
});
|
|
});
|