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";
|
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 } & {
|
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 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user