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
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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user