diff --git a/src/features/pipeline/DoneColumn.tsx b/src/features/pipeline/DoneColumn.tsx new file mode 100644 index 0000000..3510fa3 --- /dev/null +++ b/src/features/pipeline/DoneColumn.tsx @@ -0,0 +1,28 @@ +import { Badge } from '~/shared/components/ui/badge'; + +interface DoneColumnProps { + items: any[]; +} + +export function DoneColumn({ items }: DoneColumnProps) { + return ( +
+
+ Done ({items.length}) +
+
+ {items.map((item: any) => ( +
+

{item.name}

+ + {item.status} + +
+ ))} + {items.length === 0 && ( +

No completed items

+ )} +
+
+ ); +} diff --git a/src/features/pipeline/PipelineCard.tsx b/src/features/pipeline/PipelineCard.tsx new file mode 100644 index 0000000..a2e3518 --- /dev/null +++ b/src/features/pipeline/PipelineCard.tsx @@ -0,0 +1,59 @@ +import { Badge } from '~/shared/components/ui/badge'; +import { LANG_NAMES, langName } from '~/shared/lib/lang'; + +interface PipelineCardProps { + item: any; + onLanguageChange?: (lang: string) => void; + showApproveUpTo?: boolean; + onApproveUpTo?: () => void; +} + +export function PipelineCard({ item, onLanguageChange, showApproveUpTo, 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'; + + return ( +
+
+
+

{title}

+
+ {onLanguageChange ? ( + + ) : ( + {langName(item.original_language)} + )} + + {item.apple_compat === 'audio_transcode' && ( + transcode + )} + {item.job_type === 'copy' && item.apple_compat !== 'audio_transcode' && ( + copy + )} +
+
+
+ + {showApproveUpTo && onApproveUpTo && ( + + )} +
+ ); +} diff --git a/src/features/pipeline/PipelinePage.tsx b/src/features/pipeline/PipelinePage.tsx new file mode 100644 index 0000000..8ec851f --- /dev/null +++ b/src/features/pipeline/PipelinePage.tsx @@ -0,0 +1,87 @@ +import { useCallback, useEffect, useState } from 'react'; +import { api } from '~/shared/lib/api'; +import { ReviewColumn } from './ReviewColumn'; +import { QueueColumn } from './QueueColumn'; +import { ProcessingColumn } from './ProcessingColumn'; +import { DoneColumn } from './DoneColumn'; +import { ScheduleControls } from './ScheduleControls'; + +interface PipelineData { + review: any[]; + queued: any[]; + processing: any[]; + done: any[]; + noopCount: number; +} + +interface SchedulerState { + job_sleep_seconds: number; + schedule_enabled: boolean; + schedule_start: string; + schedule_end: string; +} + +interface Progress { + id: number; + seconds: number; + total: number; +} + +interface QueueStatus { + status: string; + until?: string; + seconds?: number; +} + +export function PipelinePage() { + const [data, setData] = useState(null); + const [scheduler, setScheduler] = useState(null); + const [progress, setProgress] = useState(null); + const [queueStatus, setQueueStatus] = useState(null); + const [loading, setLoading] = useState(true); + + const load = useCallback(async () => { + const [pipelineRes, schedulerRes] = await Promise.all([ + api.get('/api/review/pipeline'), + api.get('/api/execute/scheduler'), + ]); + setData(pipelineRes); + setScheduler(schedulerRes); + setLoading(false); + }, []); + + useEffect(() => { load(); }, [load]); + + // SSE for live updates + useEffect(() => { + const es = new EventSource('/api/execute/events'); + es.addEventListener('job_update', () => load()); + es.addEventListener('job_progress', (e) => { + setProgress(JSON.parse((e as MessageEvent).data)); + }); + es.addEventListener('queue_status', (e) => { + setQueueStatus(JSON.parse((e as MessageEvent).data)); + }); + return () => es.close(); + }, [load]); + + if (loading || !data) return
Loading pipeline...
; + + return ( +
+
+

Pipeline

+
+ {data.noopCount} files already processed + {scheduler && } +
+
+
+ + + + +
+
+ ); +} diff --git a/src/features/pipeline/ProcessingColumn.tsx b/src/features/pipeline/ProcessingColumn.tsx new file mode 100644 index 0000000..c526d53 --- /dev/null +++ b/src/features/pipeline/ProcessingColumn.tsx @@ -0,0 +1,62 @@ +import { Badge } from '~/shared/components/ui/badge'; + +interface ProcessingColumnProps { + items: any[]; + progress?: { id: number; seconds: number; total: number } | null; + queueStatus?: { status: string; until?: string; seconds?: number } | null; +} + +export function ProcessingColumn({ items, progress, queueStatus }: ProcessingColumnProps) { + const job = items[0]; // at most one running job + + const formatTime = (s: number) => { + const m = Math.floor(s / 60); + const sec = Math.floor(s % 60); + return `${m}:${String(sec).padStart(2, '0')}`; + }; + + return ( +
+
Processing
+
+ {queueStatus && queueStatus.status !== 'running' && ( +
+ {queueStatus.status === 'paused' && <>Paused until {queueStatus.until}} + {queueStatus.status === 'sleeping' && <>Sleeping {queueStatus.seconds}s between jobs} + {queueStatus.status === 'idle' && <>Idle} +
+ )} + + {job ? ( +
+

{job.name}

+
+ running + + {job.job_type} + +
+ + {progress && progress.total > 0 && ( +
+
+ {formatTime(progress.seconds)} + {Math.round((progress.seconds / progress.total) * 100)}% + {formatTime(progress.total)} +
+
+
+
+
+ )} +
+ ) : ( +

No active job

+ )} +
+
+ ); +} diff --git a/src/features/pipeline/QueueColumn.tsx b/src/features/pipeline/QueueColumn.tsx new file mode 100644 index 0000000..8a46ebd --- /dev/null +++ b/src/features/pipeline/QueueColumn.tsx @@ -0,0 +1,28 @@ +import { Badge } from '~/shared/components/ui/badge'; + +interface QueueColumnProps { + items: any[]; +} + +export function QueueColumn({ items }: QueueColumnProps) { + return ( +
+
+ Queued ({items.length}) +
+
+ {items.map((item: any) => ( +
+

{item.name}

+ + {item.job_type} + +
+ ))} + {items.length === 0 && ( +

Queue empty

+ )} +
+
+ ); +} diff --git a/src/features/pipeline/ReviewColumn.tsx b/src/features/pipeline/ReviewColumn.tsx new file mode 100644 index 0000000..1470be4 --- /dev/null +++ b/src/features/pipeline/ReviewColumn.tsx @@ -0,0 +1,76 @@ +import { api } from '~/shared/lib/api'; +import { PipelineCard } from './PipelineCard'; +import { SeriesCard } from './SeriesCard'; + +interface ReviewColumnProps { + items: any[]; + onMutate: () => void; +} + +export function ReviewColumn({ items, onMutate }: ReviewColumnProps) { + // Group by series (movies are standalone) + const movies = items.filter((i: any) => i.type === 'Movie'); + const seriesMap = new Map(); + + 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.get(key)!.episodes.push(item); + } + + const approveUpTo = async (planId: number) => { + await api.post(`/api/review/approve-up-to/${planId}`); + onMutate(); + }; + + // Interleave movies and series, sorted by confidence (high first) + const allItems = [ + ...movies.map((m: any) => ({ type: 'movie' as const, item: m, sortKey: m.confidence === 'high' ? 0 : 1 })), + ...[...seriesMap.values()].map(s => ({ + type: 'series' as const, + item: s, + sortKey: s.episodes.every((e: any) => e.confidence === 'high') ? 0 : 1, + })), + ].sort((a, b) => a.sortKey - b.sortKey); + + return ( +
+
+ Review ({items.length}) +
+
+ {allItems.map((entry) => { + if (entry.type === 'movie') { + return ( + { + await api.patch(`/api/review/${entry.item.item_id}/language`, { language: lang }); + onMutate(); + }} + showApproveUpTo + onApproveUpTo={() => approveUpTo(entry.item.id)} + /> + ); + } else { + return ( + + ); + } + })} + {allItems.length === 0 && ( +

No items to review

+ )} +
+
+ ); +} diff --git a/src/features/pipeline/ScheduleControls.tsx b/src/features/pipeline/ScheduleControls.tsx new file mode 100644 index 0000000..fc4ec1c --- /dev/null +++ b/src/features/pipeline/ScheduleControls.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react'; +import { api } from '~/shared/lib/api'; +import { Input } from '~/shared/components/ui/input'; +import { Button } from '~/shared/components/ui/button'; + +interface ScheduleControlsProps { + scheduler: { + job_sleep_seconds: number; + schedule_enabled: boolean; + schedule_start: string; + schedule_end: string; + }; + onUpdate: () => void; +} + +export function ScheduleControls({ scheduler, onUpdate }: ScheduleControlsProps) { + const [open, setOpen] = useState(false); + const [state, setState] = useState(scheduler); + + const save = async () => { + await api.patch('/api/execute/scheduler', state); + onUpdate(); + setOpen(false); + }; + + const startAll = async () => { + await api.post('/api/execute/start'); + onUpdate(); + }; + + return ( +
+ + + + {open && ( +
+

Schedule Settings

+ + + setState({ ...state, job_sleep_seconds: parseInt(e.target.value) || 0 })} + className="mb-3" + /> + + + + {state.schedule_enabled && ( +
+ setState({ ...state, schedule_start: e.target.value })} + className="w-24" + /> + to + setState({ ...state, schedule_end: e.target.value })} + className="w-24" + /> +
+ )} + + +
+ )} +
+ ); +} diff --git a/src/features/pipeline/SeriesCard.tsx b/src/features/pipeline/SeriesCard.tsx new file mode 100644 index 0000000..3439f73 --- /dev/null +++ b/src/features/pipeline/SeriesCard.tsx @@ -0,0 +1,79 @@ +import { useState } from 'react'; +import { api } from '~/shared/lib/api'; +import { LANG_NAMES } from '~/shared/lib/lang'; +import { PipelineCard } from './PipelineCard'; + +interface SeriesCardProps { + seriesKey: string; + seriesName: string; + episodes: any[]; + onMutate: () => void; +} + +export function SeriesCard({ seriesKey, seriesName, episodes, onMutate }: SeriesCardProps) { + const [expanded, setExpanded] = useState(false); + + const seriesLang = episodes[0]?.original_language ?? ''; + + const setSeriesLanguage = async (lang: string) => { + await api.patch(`/api/review/series/${encodeURIComponent(seriesKey)}/language`, { language: lang }); + onMutate(); + }; + + const approveSeries = async () => { + await api.post(`/api/review/series/${encodeURIComponent(seriesKey)}/approve-all`); + onMutate(); + }; + + const highCount = episodes.filter((e: any) => e.confidence === 'high').length; + const lowCount = episodes.filter((e: any) => e.confidence === 'low').length; + + return ( +
+
setExpanded(!expanded)} + > +
+ {expanded ? '▼' : '▶'} +

{seriesName}

+ {episodes.length} eps + {highCount > 0 && {highCount} ready} + {lowCount > 0 && {lowCount} review} +
+
e.stopPropagation()}> + + +
+
+ {expanded && ( +
+ {episodes.map((ep: any) => ( + { + await api.patch(`/api/review/${ep.item_id}/language`, { language: lang }); + onMutate(); + }} + /> + ))} +
+ )} +
+ ); +} diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 41e44ae..c997c86 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -51,11 +51,8 @@ function RootLayout() {
Scan - Paths - Audio - ST Extract - ST Manager - Execute + Pipeline + Subtitles
diff --git a/src/routes/pipeline.tsx b/src/routes/pipeline.tsx new file mode 100644 index 0000000..076b31b --- /dev/null +++ b/src/routes/pipeline.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { PipelinePage } from '~/features/pipeline/PipelinePage'; + +export const Route = createFileRoute('/pipeline')({ + component: PipelinePage, +});