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:
@@ -275,94 +275,17 @@ interface PipelineAudioStream {
|
||||
action: "keep" | "remove";
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// 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[];
|
||||
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;
|
||||
const planIdFor = (r: EnrichableRow): number => (r.plan_id ?? r.id) as number;
|
||||
const planIds = rows.map(planIdFor);
|
||||
@@ -422,12 +345,229 @@ app.get("/pipeline", (c) => {
|
||||
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[] }>;
|
||||
};
|
||||
|
||||
enrichWithStreamsAndReasons(review as EnrichableRow[]);
|
||||
enrichWithStreamsAndReasons(queued as EnrichableRow[]);
|
||||
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[];
|
||||
|
||||
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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user