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,94 +275,17 @@ interface PipelineAudioStream {
action: "keep" | "remove"; action: "keep" | "remove";
} }
app.get("/pipeline", (c) => { type EnrichableRow = { id?: number; plan_id?: number; item_id: number } & {
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;
// Queued gets the same enrichment as review so the card can render
// streams + transcode reasons 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,
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,
rp.id as plan_id, rp.job_type, rp.apple_compat,
rp.confidence, rp.is_noop
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;
// 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[]; transcode_reasons?: string[];
audio_streams?: PipelineAudioStream[]; audio_streams?: PipelineAudioStream[];
}; };
const enrichWithStreamsAndReasons = (rows: EnrichableRow[]) => {
/**
* 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; if (rows.length === 0) return;
const planIdFor = (r: EnrichableRow): number => (r.plan_id ?? r.id) as number; const planIdFor = (r: EnrichableRow): number => (r.plan_id ?? r.id) as number;
const planIds = rows.map(planIdFor); const planIds = rows.map(planIdFor);
@@ -422,12 +345,229 @@ app.get("/pipeline", (c) => {
r.transcode_reasons = reasonsByPlan.get(planIdFor(r)) ?? []; r.transcode_reasons = reasonsByPlan.get(planIdFor(r)) ?? [];
r.audio_streams = streamsByItem.get(r.item_id) ?? []; 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[] }>;
}; };
enrichWithStreamsAndReasons(review as EnrichableRow[]); function buildReviewGroups(db: ReturnType<typeof getDb>): { groups: ReviewGroup[]; totalItems: number } {
enrichWithStreamsAndReasons(queued as EnrichableRow[]); 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[];
return c.json({ review, reviewTotal, queued, processing, done, doneCount, jellyfinUrl }); 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") ?? "";
// 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 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,
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,
rp.id as plan_id, rp.job_type, rp.apple_compat,
rp.confidence, rp.is_noop
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;
enrichWithStreamsAndReasons(db, queued as EnrichableRow[]);
return c.json({ reviewItemsTotal, queued, processing, done, doneCount, jellyfinUrl });
}); });
// ─── List ───────────────────────────────────────────────────────────────────── // ─── List ─────────────────────────────────────────────────────────────────────