diff --git a/server/api/review.ts b/server/api/review.ts index e53f289..bc021a6 100644 --- a/server/api/review.ts +++ b/server/api/review.ts @@ -89,6 +89,61 @@ function reanalyze(db: ReturnType, itemId: number): void { } } +// ─── Pipeline: summary ─────────────────────────────────────────────────────── + +app.get('/pipeline', (c) => { + const db = getDb(); + const jellyfinUrl = getConfig('jellyfin_url') ?? ''; + + 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 + `).all(); + + 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(); + + const noops = db.prepare('SELECT COUNT(*) as count FROM review_plans WHERE is_noop = 1').get() as { count: number }; + + return c.json({ review, queued, processing, done, noopCount: noops.count, jellyfinUrl }); +}); + // ─── List ───────────────────────────────────────────────────────────────────── app.get('/', (c) => { @@ -448,57 +503,4 @@ app.patch('/series/:seriesKey/language', async (c) => { return c.json({ updated: items.length }); }); -// ─── Pipeline: summary ─────────────────────────────────────────────────────── - -app.get('/pipeline', (c) => { - const db = getDb(); - - const review = db.prepare(` - SELECT rp.*, mi.name, mi.series_name, mi.series_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(); - - 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(); - - const noops = db.prepare('SELECT COUNT(*) as count FROM review_plans WHERE is_noop = 1').get() as { count: number }; - - return c.json({ review, queued, processing, done, noopCount: noops.count }); -}); - export default app; diff --git a/src/features/pipeline/PipelineCard.tsx b/src/features/pipeline/PipelineCard.tsx index a2e3518..a7b15f3 100644 --- a/src/features/pipeline/PipelineCard.tsx +++ b/src/features/pipeline/PipelineCard.tsx @@ -3,23 +3,39 @@ import { LANG_NAMES, langName } from '~/shared/lib/lang'; interface PipelineCardProps { item: any; + jellyfinUrl: string; onLanguageChange?: (lang: string) => void; - showApproveUpTo?: boolean; onApproveUpTo?: () => void; } -export function PipelineCard({ item, onLanguageChange, showApproveUpTo, onApproveUpTo }: PipelineCardProps) { +export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApproveUpTo }: PipelineCardProps) { const title = item.type === 'Episode' ? `S${String(item.season_number).padStart(2, '0')}E${String(item.episode_number).padStart(2, '0')} — ${item.name}` : item.name; const confidenceColor = item.confidence === 'high' ? 'bg-green-50 border-green-200' : 'bg-amber-50 border-amber-200'; + const jellyfinLink = jellyfinUrl && item.jellyfin_id + ? `${jellyfinUrl}/web/index.html#!/details?id=${item.jellyfin_id}` + : null; + return ( -
+
-

{title}

+ {jellyfinLink ? ( + e.stopPropagation()} + > + {title} + + ) : ( +

{title}

+ )}
{onLanguageChange ? ( setSeriesLanguage(e.target.value)} - > - - {Object.entries(LANG_NAMES).map(([code, name]) => ( - - ))} - + )} +
+ + {/* Controls row */} +
+ {episodes.length} eps + {highCount > 0 && {highCount} ready} + {lowCount > 0 && {lowCount} review} +
+ + +
+ + {onApproveUpTo && ( +
-
+ )} + {expanded && (
{episodes.map((ep: any) => ( { await api.patch(`/api/review/${ep.item_id}/language`, { language: lang }); onMutate();