review: add /groups endpoint with server-side grouping + pagination

/api/review/pipeline no longer ships the review array — it now only
carries queue state + reviewItemsTotal. Review items live behind
/api/review/groups?offset=N&limit=25 which returns complete series
(every pending non-noop episode, bucketed by season) so the UI never
sees a split group.

Lifted enrichWithStreamsAndReasons + PipelineAudioStream to module
scope so both /pipeline (queued column) and /groups (review page)
can share the same enrichment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 12:09:27 +02:00
parent 3f848c0d31
commit 3f910873eb

View File

@@ -275,36 +275,246 @@ interface PipelineAudioStream {
action: "keep" | "remove";
}
type EnrichableRow = { id?: number; plan_id?: number; item_id: number } & {
transcode_reasons?: string[];
audio_streams?: PipelineAudioStream[];
};
/**
* Enrich review/queued rows with transcode-reason badges and pre-checked audio
* streams. Works for both the Review column (where `id` is the plan id) and
* the Queued column (where `plan_id` is explicit and `id` is the job id).
*/
function enrichWithStreamsAndReasons(db: ReturnType<typeof getDb>, rows: EnrichableRow[]): void {
if (rows.length === 0) return;
const planIdFor = (r: EnrichableRow): number => (r.plan_id ?? r.id) as number;
const planIds = rows.map(planIdFor);
const itemIds = rows.map((r) => r.item_id);
const reasonPh = 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 (${reasonPh}) AND sd.transcode_codec IS NOT NULL
`)
.all(...planIds) as { plan_id: number; codec: string | null; transcode_codec: string }[];
const reasonsByPlan = new Map<number, 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()}`);
}
const streamPh = itemIds.map(() => "?").join(",");
const streamRows = db
.prepare(`
SELECT ms.id, ms.item_id, ms.language, ms.codec, ms.channels, ms.title,
ms.is_default, sd.action
FROM media_streams ms
JOIN review_plans rp ON rp.item_id = ms.item_id
LEFT JOIN stream_decisions sd ON sd.plan_id = rp.id AND sd.stream_id = ms.id
WHERE ms.item_id IN (${streamPh}) AND ms.type = 'Audio'
ORDER BY ms.item_id, ms.stream_index
`)
.all(...itemIds) as {
id: number;
item_id: number;
language: string | null;
codec: string | null;
channels: number | null;
title: string | null;
is_default: number;
action: "keep" | "remove" | null;
}[];
const streamsByItem = new Map<number, PipelineAudioStream[]>();
for (const r of streamRows) {
if (!streamsByItem.has(r.item_id)) streamsByItem.set(r.item_id, []);
streamsByItem.get(r.item_id)!.push({
id: r.id,
language: r.language,
codec: r.codec,
channels: r.channels,
title: r.title,
is_default: r.is_default,
action: r.action ?? "keep",
});
}
for (const r of rows) {
r.transcode_reasons = reasonsByPlan.get(planIdFor(r)) ?? [];
r.audio_streams = streamsByItem.get(r.item_id) ?? [];
}
}
// ─── Review groups (paginated, always returns complete series) ──────────────
interface ReviewItemRow {
id: number;
item_id: number;
status: string;
is_noop: number;
confidence: "high" | "low";
apple_compat: ReviewPlan["apple_compat"];
job_type: "copy" | "transcode";
name: string;
series_name: string | null;
series_jellyfin_id: string | null;
jellyfin_id: string;
season_number: number | null;
episode_number: number | null;
type: "Movie" | "Episode";
container: string | null;
original_language: string | null;
orig_lang_source: string | null;
file_path: string;
transcode_reasons?: string[];
audio_streams?: PipelineAudioStream[];
}
type ReviewGroup =
| { kind: "movie"; item: ReviewItemRow }
| {
kind: "series";
seriesKey: string;
seriesName: string;
seriesJellyfinId: string | null;
episodeCount: number;
minConfidence: "high" | "low";
originalLanguage: string | null;
seasons: Array<{ season: number | null; episodes: ReviewItemRow[] }>;
};
function buildReviewGroups(db: ReturnType<typeof getDb>): { groups: ReviewGroup[]; totalItems: number } {
const rows = 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
`)
.all() as ReviewItemRow[];
const movieGroups: ReviewGroup[] = [];
interface SeriesAccum {
seriesName: string;
seriesJellyfinId: string | null;
seasons: Map<number | null, ReviewItemRow[]>;
originalLanguage: string | null;
hasLow: boolean;
}
const seriesMap = new Map<string, SeriesAccum>();
for (const row of rows) {
if (row.type === "Movie") {
movieGroups.push({ kind: "movie", item: row });
continue;
}
const key = row.series_jellyfin_id ?? row.series_name ?? String(row.item_id);
let entry = seriesMap.get(key);
if (!entry) {
entry = {
seriesName: row.series_name ?? "",
seriesJellyfinId: row.series_jellyfin_id,
seasons: new Map(),
originalLanguage: row.original_language,
hasLow: false,
};
seriesMap.set(key, entry);
}
let bucket = entry.seasons.get(row.season_number);
if (!bucket) {
bucket = [];
entry.seasons.set(row.season_number, bucket);
}
bucket.push(row);
if (row.confidence === "low") entry.hasLow = true;
}
const seriesGroups: ReviewGroup[] = [];
for (const [seriesKey, entry] of seriesMap) {
const seasonKeys = [...entry.seasons.keys()].sort((a, b) => {
if (a === null) return 1;
if (b === null) return -1;
return a - b;
});
const seasons = seasonKeys.map((season) => ({
season,
episodes: (entry.seasons.get(season) ?? []).sort((a, b) => (a.episode_number ?? 0) - (b.episode_number ?? 0)),
}));
const episodeCount = seasons.reduce((sum, s) => sum + s.episodes.length, 0);
seriesGroups.push({
kind: "series",
seriesKey,
seriesName: entry.seriesName,
seriesJellyfinId: entry.seriesJellyfinId,
episodeCount,
minConfidence: entry.hasLow ? "low" : "high",
originalLanguage: entry.originalLanguage,
seasons,
});
}
const allGroups = [...movieGroups, ...seriesGroups].sort((a, b) => {
const confA = a.kind === "movie" ? a.item.confidence : a.minConfidence;
const confB = b.kind === "movie" ? b.item.confidence : b.minConfidence;
const rankA = confA === "high" ? 0 : 1;
const rankB = confB === "high" ? 0 : 1;
if (rankA !== rankB) return rankA - rankB;
const nameA = a.kind === "movie" ? a.item.name : a.seriesName;
const nameB = b.kind === "movie" ? b.item.name : b.seriesName;
return nameA.localeCompare(nameB);
});
const totalItems =
movieGroups.length + seriesGroups.reduce((sum, g) => sum + (g.kind === "series" ? g.episodeCount : 0), 0);
return { groups: allGroups, totalItems };
}
app.get("/groups", (c) => {
const db = getDb();
const offset = Math.max(0, Number.parseInt(c.req.query("offset") ?? "0", 10) || 0);
const limit = Math.max(1, Math.min(200, Number.parseInt(c.req.query("limit") ?? "25", 10) || 25));
const { groups, totalItems } = buildReviewGroups(db);
const page = groups.slice(offset, offset + limit);
// Enrich each visible episode/movie with audio streams + transcode reasons.
const flat: EnrichableRow[] = [];
for (const g of page) {
if (g.kind === "movie") flat.push(g.item as EnrichableRow);
else for (const s of g.seasons) for (const ep of s.episodes) flat.push(ep as EnrichableRow);
}
enrichWithStreamsAndReasons(db, flat);
return c.json({
groups: page,
totalGroups: groups.length,
totalItems,
hasMore: offset + limit < groups.length,
});
});
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 = (
// Review items ship via GET /groups (paginated, always returns complete
// series). The pipeline payload only carries the total count so the column
// header can render immediately.
const reviewItemsTotal = (
db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }
).n;
// Queued gets the same enrichment as review so the card can render
// streams + transcode reasons read-only (with a "Back to review" button).
// Queued carries stream + transcode-reason enrichment so the card renders
// read-only with a "Back to review" button.
const queued = db
.prepare(`
SELECT j.id, j.item_id, j.status, j.started_at, j.completed_at,
@@ -355,79 +565,9 @@ app.get("/pipeline", (c) => {
};
const doneCount = noopRow.n + doneRow.n;
// Enrich rows that have (plan_id, item_id) with the transcode-reason
// badges and pre-checked audio streams. Used for both review and queued
// columns so the queued card can render read-only with the same info.
type EnrichableRow = { id?: number; plan_id?: number; item_id: number } & {
transcode_reasons?: string[];
audio_streams?: PipelineAudioStream[];
};
const enrichWithStreamsAndReasons = (rows: EnrichableRow[]) => {
if (rows.length === 0) return;
const planIdFor = (r: EnrichableRow): number => (r.plan_id ?? r.id) as number;
const planIds = rows.map(planIdFor);
const itemIds = rows.map((r) => r.item_id);
enrichWithStreamsAndReasons(db, queued as EnrichableRow[]);
const reasonPh = 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 (${reasonPh}) AND sd.transcode_codec IS NOT NULL
`)
.all(...planIds) as { plan_id: number; codec: string | null; transcode_codec: string }[];
const reasonsByPlan = new Map<number, 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()}`);
}
const streamPh = itemIds.map(() => "?").join(",");
const streamRows = db
.prepare(`
SELECT ms.id, ms.item_id, ms.language, ms.codec, ms.channels, ms.title,
ms.is_default, sd.action
FROM media_streams ms
JOIN review_plans rp ON rp.item_id = ms.item_id
LEFT JOIN stream_decisions sd ON sd.plan_id = rp.id AND sd.stream_id = ms.id
WHERE ms.item_id IN (${streamPh}) AND ms.type = 'Audio'
ORDER BY ms.item_id, ms.stream_index
`)
.all(...itemIds) as {
id: number;
item_id: number;
language: string | null;
codec: string | null;
channels: number | null;
title: string | null;
is_default: number;
action: "keep" | "remove" | null;
}[];
const streamsByItem = new Map<number, PipelineAudioStream[]>();
for (const r of streamRows) {
if (!streamsByItem.has(r.item_id)) streamsByItem.set(r.item_id, []);
streamsByItem.get(r.item_id)!.push({
id: r.id,
language: r.language,
codec: r.codec,
channels: r.channels,
title: r.title,
is_default: r.is_default,
action: r.action ?? "keep",
});
}
for (const r of rows) {
r.transcode_reasons = reasonsByPlan.get(planIdFor(r)) ?? [];
r.audio_streams = streamsByItem.get(r.item_id) ?? [];
}
};
enrichWithStreamsAndReasons(review as EnrichableRow[]);
enrichWithStreamsAndReasons(queued as EnrichableRow[]);
return c.json({ review, reviewTotal, queued, processing, done, doneCount, jellyfinUrl });
return c.json({ reviewItemsTotal, queued, processing, done, doneCount, jellyfinUrl });
});
// ─── List ─────────────────────────────────────────────────────────────────────