All checks were successful
Build and Push Docker Image / build (push) Successful in 1m12s
The server's old /approve-up-to/:id re-ran its own SQL ORDER BY against
ALL pending plans (no LIMIT) to decide which rows fell 'before' the target.
The pipeline UI uses a different ordering — interleaving movies with
series cards, sorting by confidence tier without a name tiebreaker, and
collapsing every episode of a series into one card. Visible position
therefore did not map to the server's iteration position, and clicking
'Approve up to here' could approve far more (or different) items than
the user expected.
- replace POST /approve-up-to/:id with POST /approve-batch { planIds: [...] }
— server only approves the plans the client lists, idempotent: skips
ids that are no longer pending, were already approved, or are noop
- ReviewColumn now builds visiblePlanIds in actual render order
(each movie's id, then every episode id of each series in series order)
and 'approve up to here' on any card sends slice(0, idx+1) of that list
- works the same for both PipelineCard (movie) and SeriesCard (whole series
through its last episode)
808 lines
34 KiB
TypeScript
808 lines
34 KiB
TypeScript
import { Hono } from "hono";
|
|
import { getAllConfig, getConfig, getDb } from "../db/index";
|
|
import { isOneOf, parseId } from "../lib/validate";
|
|
import { analyzeItem, assignTargetOrder } from "../services/analyzer";
|
|
import { buildCommand } from "../services/ffmpeg";
|
|
import { getItem, mapStream, normalizeLanguage, refreshItem } from "../services/jellyfin";
|
|
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from "../types";
|
|
|
|
const app = new Hono();
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function getSubtitleLanguages(): string[] {
|
|
return JSON.parse(getConfig("subtitle_languages") ?? '["eng","deu","spa"]');
|
|
}
|
|
|
|
function countsByFilter(db: ReturnType<typeof getDb>): Record<string, number> {
|
|
const total = (db.prepare("SELECT COUNT(*) as n FROM review_plans").get() as { n: number }).n;
|
|
const noops = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1").get() as { n: number }).n;
|
|
const pending = (
|
|
db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }
|
|
).n;
|
|
const approved = (
|
|
db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").get() as { n: number }
|
|
).n;
|
|
const skipped = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'skipped'").get() as { n: number })
|
|
.n;
|
|
const done = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'done'").get() as { n: number }).n;
|
|
const error = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'error'").get() as { n: number }).n;
|
|
const manual = (
|
|
db.prepare("SELECT COUNT(*) as n FROM media_items WHERE needs_review = 1 AND original_language IS NULL").get() as {
|
|
n: number;
|
|
}
|
|
).n;
|
|
return { all: total, needs_action: pending, noop: noops, approved, skipped, done, error, manual };
|
|
}
|
|
|
|
function buildWhereClause(filter: string): string {
|
|
switch (filter) {
|
|
case "needs_action":
|
|
return "rp.status = 'pending' AND rp.is_noop = 0";
|
|
case "noop":
|
|
return "rp.is_noop = 1";
|
|
case "manual":
|
|
return "mi.needs_review = 1 AND mi.original_language IS NULL";
|
|
case "approved":
|
|
return "rp.status = 'approved'";
|
|
case "skipped":
|
|
return "rp.status = 'skipped'";
|
|
case "done":
|
|
return "rp.status = 'done'";
|
|
case "error":
|
|
return "rp.status = 'error'";
|
|
default:
|
|
return "1=1";
|
|
}
|
|
}
|
|
|
|
type RawRow = MediaItem & {
|
|
plan_id: number | null;
|
|
plan_status: string | null;
|
|
is_noop: number | null;
|
|
plan_notes: string | null;
|
|
reviewed_at: string | null;
|
|
plan_created_at: string | null;
|
|
remove_count: number;
|
|
keep_count: number;
|
|
};
|
|
|
|
function rowToPlan(r: RawRow): ReviewPlan | null {
|
|
if (r.plan_id == null) return null;
|
|
return {
|
|
id: r.plan_id,
|
|
item_id: r.id,
|
|
status: r.plan_status ?? "pending",
|
|
is_noop: r.is_noop ?? 0,
|
|
notes: r.plan_notes,
|
|
reviewed_at: r.reviewed_at,
|
|
created_at: r.plan_created_at ?? "",
|
|
} as ReviewPlan;
|
|
}
|
|
|
|
function loadItemDetail(db: ReturnType<typeof getDb>, itemId: number) {
|
|
const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(itemId) as MediaItem | undefined;
|
|
if (!item) return { item: null, streams: [], plan: null, decisions: [], command: null };
|
|
|
|
const streams = db
|
|
.prepare("SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index")
|
|
.all(itemId) as MediaStream[];
|
|
const plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(itemId) as ReviewPlan | undefined | null;
|
|
const decisions = plan
|
|
? (db.prepare("SELECT * FROM stream_decisions WHERE plan_id = ?").all(plan.id) as StreamDecision[])
|
|
: [];
|
|
|
|
const command = plan && !plan.is_noop ? buildCommand(item, streams, decisions) : null;
|
|
|
|
return { item, streams, plan: plan ?? null, decisions, command };
|
|
}
|
|
|
|
/**
|
|
* Match old custom_titles to new stream IDs after rescan. Keys by a
|
|
* composite of (type, language, stream_index, title) so user overrides
|
|
* survive stream-id changes when Jellyfin re-probes metadata.
|
|
*/
|
|
function titleKey(s: { type: string; language: string | null; stream_index: number; title: string | null }): string {
|
|
return `${s.type}|${s.language ?? ""}|${s.stream_index}|${s.title ?? ""}`;
|
|
}
|
|
|
|
function reanalyze(db: ReturnType<typeof getDb>, itemId: number, preservedTitles?: Map<string, string>): void {
|
|
const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(itemId) as MediaItem;
|
|
if (!item) return;
|
|
|
|
const streams = db
|
|
.prepare("SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index")
|
|
.all(itemId) as MediaStream[];
|
|
const subtitleLanguages = getSubtitleLanguages();
|
|
const audioLanguages: string[] = JSON.parse(getConfig("audio_languages") ?? "[]");
|
|
const analysis = analyzeItem(
|
|
{ original_language: item.original_language, needs_review: item.needs_review, container: item.container },
|
|
streams,
|
|
{ subtitleLanguages, audioLanguages },
|
|
);
|
|
|
|
db
|
|
.prepare(`
|
|
INSERT INTO review_plans (item_id, status, is_noop, confidence, apple_compat, job_type, notes)
|
|
VALUES (?, 'pending', ?, ?, ?, ?, ?)
|
|
ON CONFLICT(item_id) DO UPDATE SET status = 'pending', is_noop = excluded.is_noop, confidence = excluded.confidence, apple_compat = excluded.apple_compat, job_type = excluded.job_type, notes = excluded.notes
|
|
`)
|
|
.run(
|
|
itemId,
|
|
analysis.is_noop ? 1 : 0,
|
|
analysis.confidence,
|
|
analysis.apple_compat,
|
|
analysis.job_type,
|
|
analysis.notes.length > 0 ? analysis.notes.join("\n") : null,
|
|
);
|
|
|
|
const plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number };
|
|
|
|
// Preserve existing custom_titles: prefer by stream_id (streams unchanged);
|
|
// fall back to titleKey match (streams regenerated after rescan).
|
|
const byStreamId = new Map<number, string | null>(
|
|
(
|
|
db.prepare("SELECT stream_id, custom_title FROM stream_decisions WHERE plan_id = ?").all(plan.id) as {
|
|
stream_id: number;
|
|
custom_title: string | null;
|
|
}[]
|
|
).map((r) => [r.stream_id, r.custom_title]),
|
|
);
|
|
const streamById = new Map(streams.map((s) => [s.id, s] as const));
|
|
|
|
db.prepare("DELETE FROM stream_decisions WHERE plan_id = ?").run(plan.id);
|
|
const insertDecision = db.prepare(
|
|
"INSERT INTO stream_decisions (plan_id, stream_id, action, target_index, custom_title, transcode_codec) VALUES (?, ?, ?, ?, ?, ?)",
|
|
);
|
|
for (const dec of analysis.decisions) {
|
|
let customTitle = byStreamId.get(dec.stream_id) ?? null;
|
|
if (!customTitle && preservedTitles) {
|
|
const s = streamById.get(dec.stream_id);
|
|
if (s) customTitle = preservedTitles.get(titleKey(s)) ?? null;
|
|
}
|
|
insertDecision.run(plan.id, dec.stream_id, dec.action, dec.target_index, customTitle, dec.transcode_codec);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* After the user toggles a stream action, re-run assignTargetOrder and
|
|
* recompute is_noop without wiping user-chosen actions or custom_titles.
|
|
*/
|
|
function recomputePlanAfterToggle(db: ReturnType<typeof getDb>, itemId: number): void {
|
|
const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(itemId) as MediaItem | undefined;
|
|
if (!item) return;
|
|
const streams = db
|
|
.prepare("SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index")
|
|
.all(itemId) as MediaStream[];
|
|
const plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number } | undefined;
|
|
if (!plan) return;
|
|
const decisions = db
|
|
.prepare("SELECT stream_id, action, target_index, transcode_codec FROM stream_decisions WHERE plan_id = ?")
|
|
.all(plan.id) as {
|
|
stream_id: number;
|
|
action: "keep" | "remove";
|
|
target_index: number | null;
|
|
transcode_codec: string | null;
|
|
}[];
|
|
|
|
const origLang = item.original_language ? normalizeLanguage(item.original_language) : null;
|
|
const audioLanguages: string[] = JSON.parse(getConfig("audio_languages") ?? "[]");
|
|
|
|
// Re-assign target_index based on current actions
|
|
const decWithIdx = decisions.map((d) => ({
|
|
stream_id: d.stream_id,
|
|
action: d.action,
|
|
target_index: null as number | null,
|
|
transcode_codec: d.transcode_codec,
|
|
}));
|
|
assignTargetOrder(streams, decWithIdx, origLang, audioLanguages);
|
|
|
|
const updateIdx = db.prepare("UPDATE stream_decisions SET target_index = ? WHERE plan_id = ? AND stream_id = ?");
|
|
for (const d of decWithIdx) updateIdx.run(d.target_index, plan.id, d.stream_id);
|
|
|
|
// Recompute is_noop: audio removed OR reordered OR subs exist OR transcode needed
|
|
const anyAudioRemoved = streams.some(
|
|
(s) => s.type === "Audio" && decWithIdx.find((d) => d.stream_id === s.id)?.action === "remove",
|
|
);
|
|
const hasSubs = streams.some((s) => s.type === "Subtitle");
|
|
const needsTranscode = decWithIdx.some((d) => d.transcode_codec != null && d.action === "keep");
|
|
|
|
const keptAudio = streams
|
|
.filter((s) => s.type === "Audio" && decWithIdx.find((d) => d.stream_id === s.id)?.action === "keep")
|
|
.sort((a, b) => a.stream_index - b.stream_index);
|
|
let audioOrderChanged = false;
|
|
for (let i = 0; i < keptAudio.length; i++) {
|
|
const dec = decWithIdx.find((d) => d.stream_id === keptAudio[i].id);
|
|
if (dec?.target_index !== i) {
|
|
audioOrderChanged = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const isNoop = !anyAudioRemoved && !audioOrderChanged && !hasSubs && !needsTranscode;
|
|
db.prepare("UPDATE review_plans SET is_noop = ? WHERE id = ?").run(isNoop ? 1 : 0, plan.id);
|
|
}
|
|
|
|
// ─── Pipeline: summary ───────────────────────────────────────────────────────
|
|
|
|
app.get("/pipeline", (c) => {
|
|
const db = getDb();
|
|
const jellyfinUrl = getConfig("jellyfin_url") ?? "";
|
|
|
|
// Cap the review column to keep the page snappy at scale; pipelines
|
|
// with thousands of pending items would otherwise ship 10k+ rows on
|
|
// every refresh and re-render every card.
|
|
const REVIEW_LIMIT = 500;
|
|
const review = db
|
|
.prepare(`
|
|
SELECT rp.*, mi.name, mi.series_name, mi.series_jellyfin_id,
|
|
mi.jellyfin_id,
|
|
mi.season_number, mi.episode_number, mi.type, mi.container,
|
|
mi.original_language, mi.orig_lang_source, mi.file_path
|
|
FROM review_plans rp
|
|
JOIN media_items mi ON mi.id = rp.item_id
|
|
WHERE rp.status = 'pending' AND rp.is_noop = 0
|
|
ORDER BY
|
|
CASE rp.confidence WHEN 'high' THEN 0 ELSE 1 END,
|
|
COALESCE(mi.series_name, mi.name),
|
|
mi.season_number, mi.episode_number
|
|
LIMIT ${REVIEW_LIMIT}
|
|
`)
|
|
.all();
|
|
const reviewTotal = (
|
|
db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }
|
|
).n;
|
|
|
|
const queued = db
|
|
.prepare(`
|
|
SELECT j.*, mi.name, mi.series_name, mi.type,
|
|
rp.job_type, rp.apple_compat
|
|
FROM jobs j
|
|
JOIN media_items mi ON mi.id = j.item_id
|
|
JOIN review_plans rp ON rp.item_id = j.item_id
|
|
WHERE j.status = 'pending'
|
|
ORDER BY j.created_at
|
|
`)
|
|
.all();
|
|
|
|
const processing = db
|
|
.prepare(`
|
|
SELECT j.*, mi.name, mi.series_name, mi.type,
|
|
rp.job_type, rp.apple_compat
|
|
FROM jobs j
|
|
JOIN media_items mi ON mi.id = j.item_id
|
|
JOIN review_plans rp ON rp.item_id = j.item_id
|
|
WHERE j.status = 'running'
|
|
`)
|
|
.all();
|
|
|
|
const done = db
|
|
.prepare(`
|
|
SELECT j.*, mi.name, mi.series_name, mi.type,
|
|
rp.job_type, rp.apple_compat
|
|
FROM jobs j
|
|
JOIN media_items mi ON mi.id = j.item_id
|
|
JOIN review_plans rp ON rp.item_id = j.item_id
|
|
WHERE j.status IN ('done', 'error')
|
|
ORDER BY j.completed_at DESC
|
|
LIMIT 50
|
|
`)
|
|
.all();
|
|
|
|
// "Done" = files already in the desired end state. Either the analyzer
|
|
// says nothing to do (is_noop=1) or a job finished. Use two indexable
|
|
// counts and add — the OR form (is_noop=1 OR status='done') can't use
|
|
// our single-column indexes and gets slow on large libraries.
|
|
const noopRow = db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1").get() as { n: number };
|
|
const doneRow = db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'done' AND is_noop = 0").get() as {
|
|
n: number;
|
|
};
|
|
const doneCount = noopRow.n + doneRow.n;
|
|
|
|
// Batch transcode reasons for all review plans in one query (avoids N+1)
|
|
const planIds = (review as { id: number }[]).map((r) => r.id);
|
|
const reasonsByPlan = new Map<number, string[]>();
|
|
if (planIds.length > 0) {
|
|
const placeholders = planIds.map(() => "?").join(",");
|
|
const allReasons = db
|
|
.prepare(`
|
|
SELECT DISTINCT sd.plan_id, ms.codec, sd.transcode_codec
|
|
FROM stream_decisions sd
|
|
JOIN media_streams ms ON ms.id = sd.stream_id
|
|
WHERE sd.plan_id IN (${placeholders}) AND sd.transcode_codec IS NOT NULL
|
|
`)
|
|
.all(...planIds) as { plan_id: number; codec: string | null; transcode_codec: string }[];
|
|
for (const r of allReasons) {
|
|
if (!reasonsByPlan.has(r.plan_id)) reasonsByPlan.set(r.plan_id, []);
|
|
reasonsByPlan.get(r.plan_id)!.push(`${(r.codec ?? "").toUpperCase()} → ${r.transcode_codec.toUpperCase()}`);
|
|
}
|
|
}
|
|
for (const item of review as { id: number; transcode_reasons?: string[] }[]) {
|
|
item.transcode_reasons = reasonsByPlan.get(item.id) ?? [];
|
|
}
|
|
|
|
return c.json({ review, reviewTotal, queued, processing, done, doneCount, jellyfinUrl });
|
|
});
|
|
|
|
// ─── List ─────────────────────────────────────────────────────────────────────
|
|
|
|
app.get("/", (c) => {
|
|
const db = getDb();
|
|
const filter = c.req.query("filter") ?? "all";
|
|
const where = buildWhereClause(filter);
|
|
|
|
const movieRows = db
|
|
.prepare(`
|
|
SELECT mi.*, rp.id as plan_id, rp.status as plan_status, rp.is_noop, rp.notes as plan_notes,
|
|
rp.reviewed_at, rp.created_at as plan_created_at,
|
|
COUNT(CASE WHEN sd.action = 'remove' THEN 1 END) as remove_count,
|
|
COUNT(CASE WHEN sd.action = 'keep' THEN 1 END) as keep_count
|
|
FROM media_items mi
|
|
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
|
LEFT JOIN stream_decisions sd ON sd.plan_id = rp.id
|
|
WHERE mi.type = 'Movie' AND ${where}
|
|
GROUP BY mi.id ORDER BY mi.name LIMIT 500
|
|
`)
|
|
.all() as RawRow[];
|
|
|
|
const movies = movieRows.map((r) => ({
|
|
item: r as unknown as MediaItem,
|
|
plan: rowToPlan(r),
|
|
removeCount: r.remove_count,
|
|
keepCount: r.keep_count,
|
|
}));
|
|
|
|
const series = db
|
|
.prepare(`
|
|
SELECT COALESCE(mi.series_jellyfin_id, mi.series_name) as series_key, mi.series_name,
|
|
MAX(mi.original_language) as original_language,
|
|
COUNT(DISTINCT mi.season_number) as season_count, COUNT(mi.id) as episode_count,
|
|
SUM(CASE WHEN rp.is_noop = 1 THEN 1 ELSE 0 END) as noop_count,
|
|
SUM(CASE WHEN rp.status = 'pending' AND rp.is_noop = 0 THEN 1 ELSE 0 END) as needs_action_count,
|
|
SUM(CASE WHEN rp.status = 'approved' THEN 1 ELSE 0 END) as approved_count,
|
|
SUM(CASE WHEN rp.status = 'skipped' THEN 1 ELSE 0 END) as skipped_count,
|
|
SUM(CASE WHEN rp.status = 'done' THEN 1 ELSE 0 END) as done_count,
|
|
SUM(CASE WHEN rp.status = 'error' THEN 1 ELSE 0 END) as error_count,
|
|
SUM(CASE WHEN mi.needs_review = 1 AND mi.original_language IS NULL THEN 1 ELSE 0 END) as manual_count
|
|
FROM media_items mi
|
|
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
|
WHERE mi.type = 'Episode' AND ${where}
|
|
GROUP BY series_key ORDER BY mi.series_name
|
|
`)
|
|
.all();
|
|
|
|
const totalCounts = countsByFilter(db);
|
|
return c.json({ movies, series, filter, totalCounts });
|
|
});
|
|
|
|
// ─── Series episodes ──────────────────────────────────────────────────────────
|
|
|
|
app.get("/series/:seriesKey/episodes", (c) => {
|
|
const db = getDb();
|
|
const seriesKey = decodeURIComponent(c.req.param("seriesKey"));
|
|
|
|
const rows = db
|
|
.prepare(`
|
|
SELECT mi.*, rp.id as plan_id, rp.status as plan_status, rp.is_noop, rp.notes as plan_notes,
|
|
rp.reviewed_at, rp.created_at as plan_created_at,
|
|
COUNT(CASE WHEN sd.action = 'remove' THEN 1 END) as remove_count, 0 as keep_count
|
|
FROM media_items mi
|
|
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
|
LEFT JOIN stream_decisions sd ON sd.plan_id = rp.id
|
|
WHERE mi.type = 'Episode'
|
|
AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?))
|
|
GROUP BY mi.id ORDER BY mi.season_number, mi.episode_number
|
|
`)
|
|
.all(seriesKey, seriesKey) as RawRow[];
|
|
|
|
const seasonMap = new Map<number | null, unknown[]>();
|
|
for (const r of rows) {
|
|
const season = (r as unknown as { season_number: number | null }).season_number ?? null;
|
|
if (!seasonMap.has(season)) seasonMap.set(season, []);
|
|
seasonMap.get(season)!.push({ item: r as unknown as MediaItem, plan: rowToPlan(r), removeCount: r.remove_count });
|
|
}
|
|
|
|
const seasons = Array.from(seasonMap.entries())
|
|
.sort(([a], [b]) => (a ?? -1) - (b ?? -1))
|
|
.map(([season, episodes]) => ({
|
|
season,
|
|
episodes,
|
|
noopCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.is_noop).length,
|
|
actionCount: (episodes as { plan: ReviewPlan | null }[]).filter(
|
|
(e) => e.plan?.status === "pending" && !e.plan.is_noop,
|
|
).length,
|
|
approvedCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.status === "approved").length,
|
|
doneCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.status === "done").length,
|
|
}));
|
|
|
|
return c.json({ seasons });
|
|
});
|
|
|
|
// ─── Approve series ───────────────────────────────────────────────────────────
|
|
|
|
app.post("/series/:seriesKey/approve-all", (c) => {
|
|
const db = getDb();
|
|
const seriesKey = decodeURIComponent(c.req.param("seriesKey"));
|
|
const pending = db
|
|
.prepare(`
|
|
SELECT rp.*, mi.id as item_id FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id
|
|
WHERE mi.type = 'Episode' AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?))
|
|
AND rp.status = 'pending' AND rp.is_noop = 0
|
|
`)
|
|
.all(seriesKey, seriesKey) as (ReviewPlan & { item_id: number })[];
|
|
for (const plan of pending) {
|
|
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
|
const { item, streams, decisions } = loadItemDetail(db, plan.item_id);
|
|
if (item)
|
|
db
|
|
.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')")
|
|
.run(plan.item_id, buildCommand(item, streams, decisions));
|
|
}
|
|
return c.json({ ok: true, count: pending.length });
|
|
});
|
|
|
|
// ─── Approve season ───────────────────────────────────────────────────────────
|
|
|
|
app.post("/season/:seriesKey/:season/approve-all", (c) => {
|
|
const db = getDb();
|
|
const seriesKey = decodeURIComponent(c.req.param("seriesKey"));
|
|
const season = Number.parseInt(c.req.param("season") ?? "", 10);
|
|
if (!Number.isFinite(season)) return c.json({ error: "invalid season" }, 400);
|
|
const pending = db
|
|
.prepare(`
|
|
SELECT rp.*, mi.id as item_id FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id
|
|
WHERE mi.type = 'Episode' AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?))
|
|
AND mi.season_number = ? AND rp.status = 'pending' AND rp.is_noop = 0
|
|
`)
|
|
.all(seriesKey, seriesKey, season) as (ReviewPlan & { item_id: number })[];
|
|
for (const plan of pending) {
|
|
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
|
const { item, streams, decisions } = loadItemDetail(db, plan.item_id);
|
|
if (item)
|
|
db
|
|
.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')")
|
|
.run(plan.item_id, buildCommand(item, streams, decisions));
|
|
}
|
|
return c.json({ ok: true, count: pending.length });
|
|
});
|
|
|
|
// ─── Approve all ──────────────────────────────────────────────────────────────
|
|
|
|
app.post("/approve-all", (c) => {
|
|
const db = getDb();
|
|
const pending = db
|
|
.prepare(
|
|
"SELECT rp.*, mi.id as item_id FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE rp.status = 'pending' AND rp.is_noop = 0",
|
|
)
|
|
.all() as (ReviewPlan & { item_id: number })[];
|
|
for (const plan of pending) {
|
|
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
|
const { item, streams, decisions } = loadItemDetail(db, plan.item_id);
|
|
if (item)
|
|
db
|
|
.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')")
|
|
.run(plan.item_id, buildCommand(item, streams, decisions));
|
|
}
|
|
return c.json({ ok: true, count: pending.length });
|
|
});
|
|
|
|
// ─── Detail ───────────────────────────────────────────────────────────────────
|
|
|
|
app.get("/:id", (c) => {
|
|
const db = getDb();
|
|
const id = parseId(c.req.param("id"));
|
|
if (id == null) return c.json({ error: "invalid id" }, 400);
|
|
const detail = loadItemDetail(db, id);
|
|
if (!detail.item) return c.notFound();
|
|
return c.json(detail);
|
|
});
|
|
|
|
// ─── Override language ────────────────────────────────────────────────────────
|
|
|
|
app.patch("/:id/language", async (c) => {
|
|
const db = getDb();
|
|
const id = parseId(c.req.param("id"));
|
|
if (id == null) return c.json({ error: "invalid id" }, 400);
|
|
const body = await c.req.json<{ language: string | null }>();
|
|
const lang = body.language || null;
|
|
db
|
|
.prepare("UPDATE media_items SET original_language = ?, orig_lang_source = 'manual', needs_review = 0 WHERE id = ?")
|
|
.run(lang ? normalizeLanguage(lang) : null, id);
|
|
reanalyze(db, id);
|
|
const detail = loadItemDetail(db, id);
|
|
if (!detail.item) return c.notFound();
|
|
return c.json(detail);
|
|
});
|
|
|
|
// ─── Edit stream title ────────────────────────────────────────────────────────
|
|
|
|
app.patch("/:id/stream/:streamId/title", async (c) => {
|
|
const db = getDb();
|
|
const itemId = parseId(c.req.param("id"));
|
|
const streamId = parseId(c.req.param("streamId"));
|
|
if (itemId == null || streamId == null) return c.json({ error: "invalid id" }, 400);
|
|
const body = await c.req.json<{ title: string }>();
|
|
const title = (body.title ?? "").trim() || null;
|
|
const plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number } | undefined;
|
|
if (!plan) return c.notFound();
|
|
db
|
|
.prepare("UPDATE stream_decisions SET custom_title = ? WHERE plan_id = ? AND stream_id = ?")
|
|
.run(title, plan.id, streamId);
|
|
const detail = loadItemDetail(db, itemId);
|
|
if (!detail.item) return c.notFound();
|
|
return c.json(detail);
|
|
});
|
|
|
|
// ─── Toggle stream action ─────────────────────────────────────────────────────
|
|
|
|
app.patch("/:id/stream/:streamId", async (c) => {
|
|
const db = getDb();
|
|
const itemId = parseId(c.req.param("id"));
|
|
const streamId = parseId(c.req.param("streamId"));
|
|
if (itemId == null || streamId == null) return c.json({ error: "invalid id" }, 400);
|
|
|
|
const body = await c.req.json<{ action: unknown }>().catch(() => ({ action: null }));
|
|
if (!isOneOf(body.action, ["keep", "remove"] as const)) {
|
|
return c.json({ error: 'action must be "keep" or "remove"' }, 400);
|
|
}
|
|
const action: "keep" | "remove" = body.action;
|
|
|
|
// Only audio streams can be toggled — subtitles are always removed (extracted to sidecar)
|
|
const stream = db.prepare("SELECT type, item_id FROM media_streams WHERE id = ?").get(streamId) as
|
|
| { type: string; item_id: number }
|
|
| undefined;
|
|
if (!stream || stream.item_id !== itemId) return c.json({ error: "stream not found on item" }, 404);
|
|
if (stream.type === "Subtitle") return c.json({ error: "Subtitle streams cannot be toggled" }, 400);
|
|
|
|
const plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number } | undefined;
|
|
if (!plan) return c.notFound();
|
|
db
|
|
.prepare("UPDATE stream_decisions SET action = ? WHERE plan_id = ? AND stream_id = ?")
|
|
.run(action, plan.id, streamId);
|
|
|
|
recomputePlanAfterToggle(db, itemId);
|
|
|
|
const detail = loadItemDetail(db, itemId);
|
|
if (!detail.item) return c.notFound();
|
|
return c.json(detail);
|
|
});
|
|
|
|
// ─── Approve ──────────────────────────────────────────────────────────────────
|
|
|
|
app.post("/:id/approve", (c) => {
|
|
const db = getDb();
|
|
const id = parseId(c.req.param("id"));
|
|
if (id == null) return c.json({ error: "invalid id" }, 400);
|
|
const plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(id) as ReviewPlan | undefined;
|
|
if (!plan) return c.notFound();
|
|
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
|
if (!plan.is_noop) {
|
|
const { item, streams, decisions } = loadItemDetail(db, id);
|
|
if (item)
|
|
db
|
|
.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')")
|
|
.run(id, buildCommand(item, streams, decisions));
|
|
}
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
// ─── Unapprove ───────────────────────────────────────────────────────────────
|
|
|
|
// ─── Retry failed job ─────────────────────────────────────────────────────────
|
|
|
|
app.post("/:id/retry", (c) => {
|
|
const db = getDb();
|
|
const id = parseId(c.req.param("id"));
|
|
if (id == null) return c.json({ error: "invalid id" }, 400);
|
|
const plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(id) as ReviewPlan | undefined;
|
|
if (!plan) return c.notFound();
|
|
if (plan.status !== "error") return c.json({ ok: false, error: "Only failed plans can be retried" }, 409);
|
|
|
|
// Clear old errored/done jobs for this item so the queue starts clean
|
|
db.prepare("DELETE FROM jobs WHERE item_id = ? AND status IN ('error', 'done')").run(id);
|
|
|
|
// Rebuild the command from the current decisions (streams may have been edited)
|
|
const { item, command } = loadItemDetail(db, id);
|
|
if (!item || !command) return c.json({ ok: false, error: "Cannot rebuild command" }, 400);
|
|
|
|
db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')").run(id, command);
|
|
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
app.post("/:id/unapprove", (c) => {
|
|
const db = getDb();
|
|
const id = parseId(c.req.param("id"));
|
|
if (id == null) return c.json({ error: "invalid id" }, 400);
|
|
const plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(id) as ReviewPlan | undefined;
|
|
if (!plan) return c.notFound();
|
|
if (plan.status !== "approved")
|
|
return c.json({ ok: false, error: "Can only unapprove items with status approved" }, 409);
|
|
// Only allow if the associated job hasn't started yet
|
|
const job = db.prepare("SELECT * FROM jobs WHERE item_id = ? ORDER BY created_at DESC LIMIT 1").get(id) as
|
|
| { id: number; status: string }
|
|
| undefined;
|
|
if (job && job.status !== "pending")
|
|
return c.json({ ok: false, error: "Job already started — cannot unapprove" }, 409);
|
|
// Delete the pending job and revert plan status
|
|
if (job) db.prepare("DELETE FROM jobs WHERE id = ?").run(job.id);
|
|
db.prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE id = ?").run(plan.id);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
// ─── Skip / Unskip ───────────────────────────────────────────────────────────
|
|
|
|
app.post("/skip-all", (c) => {
|
|
const db = getDb();
|
|
const result = db
|
|
.prepare(
|
|
"UPDATE review_plans SET status = 'skipped', reviewed_at = datetime('now') WHERE status = 'pending' AND is_noop = 0",
|
|
)
|
|
.run();
|
|
return c.json({ ok: true, skipped: result.changes });
|
|
});
|
|
|
|
app.post("/:id/skip", (c) => {
|
|
const db = getDb();
|
|
const id = parseId(c.req.param("id"));
|
|
if (id == null) return c.json({ error: "invalid id" }, 400);
|
|
db.prepare("UPDATE review_plans SET status = 'skipped', reviewed_at = datetime('now') WHERE item_id = ?").run(id);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
app.post("/:id/unskip", (c) => {
|
|
const db = getDb();
|
|
const id = parseId(c.req.param("id"));
|
|
if (id == null) return c.json({ error: "invalid id" }, 400);
|
|
db
|
|
.prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE item_id = ? AND status = 'skipped'")
|
|
.run(id);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
// ─── Rescan ───────────────────────────────────────────────────────────────────
|
|
|
|
app.post("/:id/rescan", async (c) => {
|
|
const db = getDb();
|
|
const id = parseId(c.req.param("id"));
|
|
if (id == null) return c.json({ error: "invalid id" }, 400);
|
|
const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(id) as MediaItem | undefined;
|
|
if (!item) return c.notFound();
|
|
|
|
const cfg = getAllConfig();
|
|
const jfCfg = { url: cfg.jellyfin_url, apiKey: cfg.jellyfin_api_key, userId: cfg.jellyfin_user_id };
|
|
|
|
// Trigger Jellyfin's internal metadata probe and wait for it to finish
|
|
// so the streams we fetch afterwards reflect the current file on disk.
|
|
await refreshItem(jfCfg, item.jellyfin_id);
|
|
|
|
// Snapshot custom_titles keyed by stable properties, since replacing
|
|
// media_streams cascades away all stream_decisions.
|
|
const preservedTitles = new Map<string, string>();
|
|
const oldRows = db
|
|
.prepare(`
|
|
SELECT ms.type, ms.language, ms.stream_index, ms.title, sd.custom_title
|
|
FROM stream_decisions sd
|
|
JOIN media_streams ms ON ms.id = sd.stream_id
|
|
JOIN review_plans rp ON rp.id = sd.plan_id
|
|
WHERE rp.item_id = ? AND sd.custom_title IS NOT NULL
|
|
`)
|
|
.all(id) as {
|
|
type: string;
|
|
language: string | null;
|
|
stream_index: number;
|
|
title: string | null;
|
|
custom_title: string;
|
|
}[];
|
|
for (const r of oldRows) {
|
|
preservedTitles.set(titleKey(r), r.custom_title);
|
|
}
|
|
|
|
const fresh = await getItem(jfCfg, item.jellyfin_id);
|
|
if (fresh) {
|
|
const insertStream = db.prepare(`
|
|
INSERT INTO media_streams (item_id, stream_index, type, codec, language, language_display,
|
|
title, is_default, is_forced, is_hearing_impaired, channels, channel_layout, bit_rate, sample_rate)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
db.prepare("DELETE FROM media_streams WHERE item_id = ?").run(id);
|
|
for (const jStream of fresh.MediaStreams ?? []) {
|
|
if (jStream.IsExternal) continue; // skip external subs — not embedded in container
|
|
const s = mapStream(jStream);
|
|
insertStream.run(
|
|
id,
|
|
s.stream_index,
|
|
s.type,
|
|
s.codec,
|
|
s.language,
|
|
s.language_display,
|
|
s.title,
|
|
s.is_default,
|
|
s.is_forced,
|
|
s.is_hearing_impaired,
|
|
s.channels,
|
|
s.channel_layout,
|
|
s.bit_rate,
|
|
s.sample_rate,
|
|
);
|
|
}
|
|
}
|
|
|
|
reanalyze(db, id, preservedTitles);
|
|
const detail = loadItemDetail(db, id);
|
|
if (!detail.item) return c.notFound();
|
|
return c.json(detail);
|
|
});
|
|
|
|
// ─── Pipeline: approve a batch of plan IDs ──────────────────────────────────
|
|
//
|
|
// The pipeline UI groups episodes into series cards and interleaves them
|
|
// with movies in a frontend-specific order, so we can't reconstruct
|
|
// "up to here" by re-running an ORDER BY on the server. The client knows
|
|
// exactly which plans are visually before (and including) the clicked card
|
|
// and sends them as an explicit list.
|
|
|
|
app.post("/approve-batch", async (c) => {
|
|
const body = await c.req.json<{ planIds: unknown }>().catch(() => ({ planIds: null }));
|
|
if (!Array.isArray(body.planIds) || !body.planIds.every((id) => typeof id === "number" && id > 0)) {
|
|
return c.json({ error: "planIds must be an array of positive integers" }, 400);
|
|
}
|
|
const planIds = body.planIds as number[];
|
|
if (planIds.length === 0) return c.json({ approved: 0 });
|
|
const db = getDb();
|
|
const toApprove = planIds;
|
|
|
|
// Only approve plans that are still pending and not noop. Skip silently
|
|
// if a plan was already approved/skipped or doesn't exist — keeps batch
|
|
// idempotent under concurrent edits.
|
|
let approved = 0;
|
|
for (const planId of toApprove) {
|
|
const planRow = db
|
|
.prepare(
|
|
"SELECT id, item_id, status, is_noop, job_type FROM review_plans WHERE id = ? AND status = 'pending' AND is_noop = 0",
|
|
)
|
|
.get(planId) as { id: number; item_id: number; job_type: string } | undefined;
|
|
if (!planRow) continue;
|
|
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(planId);
|
|
const detail = loadItemDetail(db, planRow.item_id);
|
|
if (detail.item && detail.command) {
|
|
db
|
|
.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, ?, 'pending')")
|
|
.run(planRow.item_id, detail.command, planRow.job_type);
|
|
approved++;
|
|
}
|
|
}
|
|
|
|
return c.json({ approved });
|
|
});
|
|
|
|
// ─── Pipeline: series language ───────────────────────────────────────────────
|
|
|
|
app.patch("/series/:seriesKey/language", async (c) => {
|
|
const seriesKey = decodeURIComponent(c.req.param("seriesKey"));
|
|
const { language } = await c.req.json<{ language: string }>();
|
|
const db = getDb();
|
|
|
|
const items = db
|
|
.prepare(
|
|
"SELECT id FROM media_items WHERE series_jellyfin_id = ? OR (series_jellyfin_id IS NULL AND series_name = ?)",
|
|
)
|
|
.all(seriesKey, seriesKey) as { id: number }[];
|
|
|
|
const normalizedLang = language ? normalizeLanguage(language) : null;
|
|
for (const item of items) {
|
|
db
|
|
.prepare("UPDATE media_items SET original_language = ?, orig_lang_source = 'manual', needs_review = 0 WHERE id = ?")
|
|
.run(normalizedLang, item.id);
|
|
}
|
|
|
|
// Re-analyze all episodes
|
|
for (const item of items) {
|
|
reanalyze(db, item.id);
|
|
}
|
|
|
|
return c.json({ updated: items.length });
|
|
});
|
|
|
|
export default app;
|