pipeline UI polish: jellyfin deep-links on titles, hover-to-show approve buttons, series approve-up-to
All checks were successful
Build and Push Docker Image / build (push) Successful in 37s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-28 01:14:19 +01:00
parent 7cefd9bf04
commit 9c5a793a47
5 changed files with 151 additions and 88 deletions

View File

@@ -89,6 +89,61 @@ function reanalyze(db: ReturnType<typeof getDb>, 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;

View File

@@ -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 (
<div className={`rounded-lg border p-3 ${confidenceColor}`}>
<div className={`group rounded-lg border p-3 ${confidenceColor}`}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{title}</p>
{jellyfinLink ? (
<a
href={jellyfinLink}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium truncate block hover:text-blue-600 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{title}
</a>
) : (
<p className="text-sm font-medium truncate">{title}</p>
)}
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
{onLanguageChange ? (
<select
@@ -46,10 +62,10 @@ export function PipelineCard({ item, onLanguageChange, showApproveUpTo, onApprov
</div>
</div>
{showApproveUpTo && onApproveUpTo && (
{onApproveUpTo && (
<button
onClick={onApproveUpTo}
className="mt-2 w-full text-xs py-1 rounded bg-blue-600 text-white hover:bg-blue-700 cursor-pointer"
className="mt-2 w-full text-xs py-1 rounded bg-blue-600 text-white hover:bg-blue-700 cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
>
Approve up to here
</button>

View File

@@ -12,6 +12,7 @@ interface PipelineData {
processing: any[];
done: any[];
noopCount: number;
jellyfinUrl: string;
}
interface SchedulerState {
@@ -77,7 +78,7 @@ export function PipelinePage() {
</div>
</div>
<div className="flex flex-1 gap-4 p-4 overflow-x-auto">
<ReviewColumn items={data.review} onMutate={load} />
<ReviewColumn items={data.review} jellyfinUrl={data.jellyfinUrl} onMutate={load} />
<QueueColumn items={data.queued} />
<ProcessingColumn items={data.processing} progress={progress} queueStatus={queueStatus} />
<DoneColumn items={data.done} />

View File

@@ -4,18 +4,19 @@ import { SeriesCard } from './SeriesCard';
interface ReviewColumnProps {
items: any[];
jellyfinUrl: string;
onMutate: () => void;
}
export function ReviewColumn({ items, onMutate }: ReviewColumnProps) {
export function ReviewColumn({ items, jellyfinUrl, onMutate }: ReviewColumnProps) {
// Group by series (movies are standalone)
const movies = items.filter((i: any) => i.type === 'Movie');
const seriesMap = new Map<string, { name: string; key: string; episodes: any[] }>();
const seriesMap = new Map<string, { name: string; key: string; jellyfinId: string | null; episodes: any[] }>();
for (const item of items.filter((i: any) => i.type === 'Episode')) {
const key = item.series_jellyfin_id ?? item.series_name;
if (!seriesMap.has(key)) {
seriesMap.set(key, { name: item.series_name, key, episodes: [] });
seriesMap.set(key, { name: item.series_name, key, jellyfinId: item.series_jellyfin_id, episodes: [] });
}
seriesMap.get(key)!.episodes.push(item);
}
@@ -35,6 +36,12 @@ export function ReviewColumn({ items, onMutate }: ReviewColumnProps) {
})),
].sort((a, b) => a.sortKey - b.sortKey);
// For "approve up to here" on series, use the last episode's plan ID
const lastPlanId = (series: { episodes: any[] }) => {
const eps = series.episodes;
return eps[eps.length - 1]?.id;
};
return (
<div className="flex flex-col w-80 min-w-80 bg-gray-50 rounded-lg">
<div className="px-3 py-2 border-b font-medium text-sm">
@@ -47,11 +54,11 @@ export function ReviewColumn({ items, onMutate }: ReviewColumnProps) {
<PipelineCard
key={entry.item.id}
item={entry.item}
jellyfinUrl={jellyfinUrl}
onLanguageChange={async (lang) => {
await api.patch(`/api/review/${entry.item.item_id}/language`, { language: lang });
onMutate();
}}
showApproveUpTo
onApproveUpTo={() => approveUpTo(entry.item.id)}
/>
);
@@ -61,8 +68,11 @@ export function ReviewColumn({ items, onMutate }: ReviewColumnProps) {
key={entry.item.key}
seriesKey={entry.item.key}
seriesName={entry.item.name}
jellyfinUrl={jellyfinUrl}
seriesJellyfinId={entry.item.jellyfinId}
episodes={entry.item.episodes}
onMutate={onMutate}
onApproveUpTo={() => approveUpTo(lastPlanId(entry.item))}
/>
);
}

View File

@@ -6,11 +6,14 @@ import { PipelineCard } from './PipelineCard';
interface SeriesCardProps {
seriesKey: string;
seriesName: string;
jellyfinUrl: string;
seriesJellyfinId: string | null;
episodes: any[];
onMutate: () => void;
onApproveUpTo?: () => void;
}
export function SeriesCard({ seriesKey, seriesName, episodes, onMutate }: SeriesCardProps) {
export function SeriesCard({ seriesKey, seriesName, jellyfinUrl, seriesJellyfinId, episodes, onMutate, onApproveUpTo }: SeriesCardProps) {
const [expanded, setExpanded] = useState(false);
const seriesLang = episodes[0]?.original_language ?? '';
@@ -28,44 +31,75 @@ export function SeriesCard({ seriesKey, seriesName, episodes, onMutate }: Series
const highCount = episodes.filter((e: any) => e.confidence === 'high').length;
const lowCount = episodes.filter((e: any) => e.confidence === 'low').length;
const jellyfinLink = jellyfinUrl && seriesJellyfinId
? `${jellyfinUrl}/web/index.html#!/details?id=${seriesJellyfinId}`
: null;
return (
<div className="rounded-lg border bg-white">
<div className="group rounded-lg border bg-white">
{/* Title row */}
<div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-gray-50"
className="flex items-center gap-2 px-3 pt-3 pb-1 cursor-pointer hover:bg-gray-50"
onClick={() => setExpanded(!expanded)}
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-gray-400">{expanded ? '▼' : '▶'}</span>
<span className="text-xs text-gray-400 shrink-0">{expanded ? '▼' : '▶'}</span>
{jellyfinLink ? (
<a
href={jellyfinLink}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium truncate hover:text-blue-600 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{seriesName}
</a>
) : (
<p className="text-sm font-medium truncate">{seriesName}</p>
<span className="text-xs text-gray-500">{episodes.length} eps</span>
{highCount > 0 && <span className="text-xs text-green-600">{highCount} ready</span>}
{lowCount > 0 && <span className="text-xs text-amber-600">{lowCount} review</span>}
</div>
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<select
className="h-6 text-xs border border-gray-300 rounded px-1 bg-white"
value={seriesLang}
onChange={(e) => setSeriesLanguage(e.target.value)}
>
<option value="">unknown</option>
{Object.entries(LANG_NAMES).map(([code, name]) => (
<option key={code} value={code}>{name}</option>
))}
</select>
)}
</div>
{/* Controls row */}
<div className="flex items-center gap-2 px-3 pb-3 pt-1">
<span className="text-xs text-gray-500 shrink-0">{episodes.length} eps</span>
{highCount > 0 && <span className="text-xs text-green-600 shrink-0">{highCount} ready</span>}
{lowCount > 0 && <span className="text-xs text-amber-600 shrink-0">{lowCount} review</span>}
<div className="flex-1" />
<select
className="h-6 text-xs border border-gray-300 rounded px-1 bg-white shrink-0"
value={seriesLang}
onChange={(e) => { e.stopPropagation(); setSeriesLanguage(e.target.value); }}
>
<option value="">unknown</option>
{Object.entries(LANG_NAMES).map(([code, name]) => (
<option key={code} value={code}>{name}</option>
))}
</select>
<button
onClick={(e) => { e.stopPropagation(); approveSeries(); }}
className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 cursor-pointer whitespace-nowrap shrink-0"
>
Approve all
</button>
</div>
{onApproveUpTo && (
<div className="px-3 pb-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={approveSeries}
className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 cursor-pointer whitespace-nowrap"
onClick={onApproveUpTo}
className="w-full text-xs py-1 rounded bg-blue-600 text-white hover:bg-blue-700 cursor-pointer"
>
Approve all
Approve up to here
</button>
</div>
</div>
)}
{expanded && (
<div className="border-t px-3 pb-3 space-y-2 pt-2">
{episodes.map((ep: any) => (
<PipelineCard
key={ep.id}
item={ep}
jellyfinUrl={jellyfinUrl}
onLanguageChange={async (lang) => {
await api.patch(`/api/review/${ep.item_id}/language`, { language: lang });
onMutate();