diff --git a/package.json b/package.json index e48192c..2284917 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "netfelix-audio-fix", - "version": "2026.04.21.4", + "version": "2026.04.21.5", "scripts": { "dev:server": "NODE_ENV=development bun --hot server/index.tsx", "dev:client": "vite", diff --git a/server/api/review.ts b/server/api/review.ts index 492de19..eaf46f4 100644 --- a/server/api/review.ts +++ b/server/api/review.ts @@ -633,19 +633,17 @@ type ReviewGroup = seasons: Array<{ season: number | null; episodes: ReviewItemRow[] }>; }; -export type InboxSort = "scan_asc" | "scan_desc" | "name_asc" | "name_desc"; +export type GroupSort = "scan_asc" | "scan_desc" | "name_asc" | "name_desc" | "class"; export interface BuildReviewGroupsOpts { bucket: "inbox" | "review"; - sort?: InboxSort; + sort?: GroupSort; } -function orderClause(bucket: "inbox" | "review", sort?: InboxSort): string { - if (bucket === "review") { +function orderClause(sort?: GroupSort): string { + if (sort === "class") return `CASE rp.auto_class WHEN 'auto_heuristic' THEN 0 WHEN 'manual' THEN 1 ELSE 2 END, - COALESCE(mi.series_name, mi.name), - mi.season_number, mi.episode_number`; - } + COALESCE(mi.series_name, mi.name), mi.season_number, mi.episode_number`; if (sort === "scan_desc") return "mi.last_scanned_at DESC, mi.id DESC"; if (sort === "name_asc") return "COALESCE(mi.series_name, mi.name) ASC, mi.season_number, mi.episode_number"; if (sort === "name_desc") return "COALESCE(mi.series_name, mi.name) DESC, mi.season_number, mi.episode_number"; @@ -657,7 +655,8 @@ export function buildReviewGroups( opts: BuildReviewGroupsOpts, ): { groups: ReviewGroup[]; totalItems: number } { const sortedFilter = opts.bucket === "inbox" ? "rp.sorted = 0" : "rp.sorted = 1"; - const order = orderClause(opts.bucket, opts.sort); + const defaultSort: GroupSort = opts.bucket === "inbox" ? "scan_asc" : "class"; + const order = orderClause(opts.sort ?? defaultSort); const rows = db .prepare(` SELECT rp.*, mi.name, mi.series_name, mi.series_key, @@ -728,12 +727,21 @@ export function buildReviewGroups( }); } - // For inbox, preserve the SQL order (scan time or name). For review, - // rank by auto_class so auto-approvable items float to the top. + const effectiveSort = opts.sort ?? (opts.bucket === "inbox" ? "scan_asc" : "class"); + let allGroups: ReviewGroup[]; - if (opts.bucket === "inbox") { - // Interleave movies and series in the order their first row appeared - // in the SQL result set so scan-time ordering stays intact. + if (effectiveSort === "class") { + // Class sort: rank by auto_class in JS so auto-approvable items float top. + allGroups = [...movieGroups, ...seriesGroups].sort((a, b) => { + const rankA = a.kind === "movie" ? autoClassRank(a.item.auto_class) : a.readyCount > 0 ? 0 : 1; + const rankB = b.kind === "movie" ? autoClassRank(b.item.auto_class) : b.readyCount > 0 ? 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); + }); + } else { + // Scan-time / name sorts: interleave movies and series in SQL row order. const groupOrder: ReviewGroup[] = []; const seen = new Set(); for (const row of rows) { @@ -755,15 +763,6 @@ export function buildReviewGroups( } } allGroups = groupOrder; - } else { - allGroups = [...movieGroups, ...seriesGroups].sort((a, b) => { - const rankA = a.kind === "movie" ? autoClassRank(a.item.auto_class) : a.readyCount > 0 ? 0 : 1; - const rankB = b.kind === "movie" ? autoClassRank(b.item.auto_class) : b.readyCount > 0 ? 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 = @@ -784,9 +783,8 @@ app.get("/groups", (c) => { const bucketParam = c.req.query("bucket") ?? "review"; const bucket = bucketParam === "inbox" ? "inbox" : "review"; - const sortParam = c.req.query("sort") as InboxSort | undefined; - const sort = bucket === "inbox" ? (sortParam ?? "scan_asc") : undefined; - const { groups, totalItems } = buildReviewGroups(db, { bucket, sort }); + const sortParam = c.req.query("sort") as GroupSort | undefined; + const { groups, totalItems } = buildReviewGroups(db, { bucket, sort: sortParam }); const page = groups.slice(offset, offset + limit); const flat: EnrichableRow[] = []; diff --git a/src/features/pipeline/ColumnShell.tsx b/src/features/pipeline/ColumnShell.tsx index 605939a..55fa80d 100644 --- a/src/features/pipeline/ColumnShell.tsx +++ b/src/features/pipeline/ColumnShell.tsx @@ -9,14 +9,21 @@ export interface ColumnAction { title?: string; } +export interface SortOption { + value: T; + label: string; +} + interface ColumnShellProps { title: string; count: ReactNode; subtitle?: ReactNode; backward?: ColumnAction; - /** Middle slot: accepts a ColumnAction button or any ReactNode (e.g. a dropdown). */ middle?: ColumnAction | ReactNode; forward?: ColumnAction; + sortOptions?: SortOption[]; + sortValue?: string; + onSortChange?: (value: string) => void; children: ReactNode; } @@ -45,18 +52,18 @@ function isColumnAction(v: unknown): v is ColumnAction { return typeof v === "object" && v !== null && "label" in v && "onClick" in v; } -/** - * Equal-width pipeline column with a fixed three-row header (title + count, - * subtitle, button row) and a scrolling body. All five pipeline columns share - * this shell so widths, spacing, and the left/middle/right button layout stay - * consistent — which in turn makes the pipeline direction readable at a glance. - * - * The subtitle and button rows are always reserved — when a column has no - * subtitle we render an invisible spacer to keep every column's header the - * same height; buttons passed as disabled still occupy their slot so the - * header never jumps between states. - */ -export function ColumnShell({ title, count, subtitle, backward, middle, forward, children }: ColumnShellProps) { +export function ColumnShell({ + title, + count, + subtitle, + backward, + middle, + forward, + sortOptions, + sortValue, + onSortChange, + children, +}: ColumnShellProps) { return (
@@ -64,11 +71,6 @@ export function ColumnShell({ title, count, subtitle, backward, middle, forward, {title} ({count})
{subtitle}
- {/* - auto|1fr|auto: left/right buttons take their natural width (no - wrapping on "← Back to inbox" / "Approve auto →"), the middle - column flexes and centers the skip button if present. - */}
{backward && }
@@ -77,6 +79,22 @@ export function ColumnShell({ title, count, subtitle, backward, middle, forward,
{forward && }
+ {sortOptions && sortOptions.length > 0 && ( +
+ Sort + +
+ )}
{children}
); diff --git a/src/features/pipeline/DoneColumn.tsx b/src/features/pipeline/DoneColumn.tsx index e2fff85..9cf1e47 100644 --- a/src/features/pipeline/DoneColumn.tsx +++ b/src/features/pipeline/DoneColumn.tsx @@ -2,15 +2,25 @@ import { Link } from "@tanstack/react-router"; import { Badge } from "~/shared/components/ui/badge"; import { api } from "~/shared/lib/api"; import type { PipelineJobItem } from "~/shared/lib/types"; -import { ColumnShell } from "./ColumnShell"; +import { ColumnShell, type SortOption } from "./ColumnShell"; +import type { JobSort } from "./PipelinePage"; + +const DONE_SORT_OPTIONS: SortOption[] = [ + { value: "added_desc", label: "↑ Completed" }, + { value: "added_asc", label: "↓ Completed" }, + { value: "name_asc", label: "↓ Name" }, + { value: "name_desc", label: "↑ Name" }, +]; interface DoneColumnProps { items: PipelineJobItem[]; doneCount: number; onMutate: () => void; + sort: JobSort; + onChangeSort: (next: JobSort) => void; } -export function DoneColumn({ items, doneCount, onMutate }: DoneColumnProps) { +export function DoneColumn({ items, doneCount, onMutate, sort, onChangeSort }: DoneColumnProps) { const clear = async () => { await api.post("/api/execute/clear-completed"); onMutate(); @@ -47,6 +57,9 @@ export function DoneColumn({ items, doneCount, onMutate }: DoneColumnProps) { subtitle={`${doneCount} in desired state`} backward={backward} forward={forward} + sortOptions={DONE_SORT_OPTIONS} + sortValue={sort} + onSortChange={(v) => onChangeSort(v as JobSort)} > {items.map((item) => (
diff --git a/src/features/pipeline/InboxColumn.tsx b/src/features/pipeline/InboxColumn.tsx index c2d2aa2..f1604b1 100644 --- a/src/features/pipeline/InboxColumn.tsx +++ b/src/features/pipeline/InboxColumn.tsx @@ -1,15 +1,15 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "~/shared/lib/api"; import type { ReviewGroup, ReviewGroupsResponse } from "~/shared/lib/types"; -import { ColumnShell } from "./ColumnShell"; +import { ColumnShell, type SortOption } from "./ColumnShell"; import { PipelineCard } from "./PipelineCard"; import { SeriesCard } from "./SeriesCard"; const PAGE_SIZE = 25; -type InboxSort = "scan_asc" | "scan_desc" | "name_asc" | "name_desc"; +export type InboxSort = "scan_asc" | "scan_desc" | "name_asc" | "name_desc"; -const SORT_OPTIONS: { value: InboxSort; label: string }[] = [ +export const INBOX_SORT_OPTIONS: SortOption[] = [ { value: "scan_asc", label: "↓ Scan time" }, { value: "scan_desc", label: "↑ Scan time" }, { value: "name_asc", label: "↓ Name" }, @@ -42,9 +42,6 @@ export function InboxColumn({ const [loadingMore, setLoadingMore] = useState(false); const sentinelRef = useRef(null); - // Optimistic mirror of the auto-process checkbox so a click flips the - // box immediately rather than waiting for the server roundtrip. See the - // matching rationale in ProcessingColumn. const [localAutoProcessing, setLocalAutoProcessing] = useState(autoProcessing); useEffect(() => { setLocalAutoProcessing(autoProcessing); @@ -94,43 +91,40 @@ export function InboxColumn({ onMutate(); }; - // Progress bar fills the subtitle slot during an active sort so the user - // sees real work happening instead of a frozen button. The auto-process - // toggle hides while a sort runs — it can't be flipped meaningfully - // mid-pass and the progress deserves the full line of visual real estate. const sorting = sortProgress !== null; const pct = sortProgress && sortProgress.total > 0 ? Math.round((sortProgress.processed / sortProgress.total) * 100) : 0; - const subtitle = sorting ? ( -
-
- - Processing {sortProgress.processed}/{sortProgress.total} - -
-
-
-
+ + // Checkbox always visible. Progress bar shows below it when sorting. + const subtitle = ( +
+ + {sorting && ( +
+ + {sortProgress.processed}/{sortProgress.total} + +
+
+
+
+ )}
- ) : ( - ); const backward = sorting ? { label: "Stop Sorting", onClick: stopProcess, danger: true } : undefined; - - // Sort dropdown + Process Inbox button share the forward slot const forward = sorting ? undefined : { @@ -141,28 +135,16 @@ export function InboxColumn({ title: "Process inbox to Queue / Review", }; - const sortDropdown = !sorting ? ( - - ) : undefined; - return ( onChangeSort(v as InboxSort)} >
{groups.map((group) => { @@ -186,7 +168,6 @@ export function InboxColumn({ originalLanguage={group.originalLanguage} onMutate={onMutate} onProcess={() => { - // Process all episodes in this series const ids = group.seasons.flatMap((s) => s.episodes.map((ep) => ep.item_id)); Promise.all(ids.map((id) => api.post(`/api/review/${id}/process`))).then(() => onMutate()); }} diff --git a/src/features/pipeline/PipelinePage.tsx b/src/features/pipeline/PipelinePage.tsx index 451976f..e0fe120 100644 --- a/src/features/pipeline/PipelinePage.tsx +++ b/src/features/pipeline/PipelinePage.tsx @@ -1,12 +1,12 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "~/shared/lib/api"; -import type { PipelineData, ReviewGroupsResponse } from "~/shared/lib/types"; +import type { PipelineData, PipelineJobItem, ReviewGroupsResponse } from "~/shared/lib/types"; import { DoneColumn } from "./DoneColumn"; -import { InboxColumn } from "./InboxColumn"; +import { type InboxSort, InboxColumn } from "./InboxColumn"; import { PipelineHeader } from "./PipelineHeader"; import { ProcessingColumn } from "./ProcessingColumn"; import { QueueColumn } from "./QueueColumn"; -import { ReviewColumn } from "./ReviewColumn"; +import { type ReviewSort, ReviewColumn } from "./ReviewColumn"; interface Progress { id: number; @@ -25,7 +25,15 @@ interface SortProgress { total: number; } -type InboxSort = "scan_asc" | "scan_desc" | "name_asc" | "name_desc"; +export type JobSort = "name_asc" | "name_desc" | "added_asc" | "added_desc"; + +function sortJobs(items: PipelineJobItem[], sort: JobSort): PipelineJobItem[] { + const sorted = [...items]; + if (sort === "name_asc") return sorted.sort((a, b) => a.name.localeCompare(b.name)); + if (sort === "name_desc") return sorted.sort((a, b) => b.name.localeCompare(a.name)); + if (sort === "added_desc") return sorted.reverse(); + return sorted; // added_asc = default backend order +} export function PipelinePage() { const [data, setData] = useState(null); @@ -35,8 +43,14 @@ export function PipelinePage() { const [queueStatus, setQueueStatus] = useState(null); const [sortProgress, setSortProgress] = useState(null); const [loading, setLoading] = useState(true); + + // Sort state for all columns const [inboxSort, setInboxSort] = useState("scan_asc"); const inboxSortRef = useRef("scan_asc"); + const [reviewSort, setReviewSort] = useState("class"); + const reviewSortRef = useRef("class"); + const [queueSort, setQueueSort] = useState("added_asc"); + const [doneSort, setDoneSort] = useState("added_desc"); const loadPipeline = useCallback(async () => { const res = await api.get("/api/review/pipeline"); @@ -44,10 +58,11 @@ export function PipelinePage() { }, []); const loadGroups = useCallback(async () => { - const sort = inboxSortRef.current; + const iSort = inboxSortRef.current; + const rSort = reviewSortRef.current; const [inbox, review] = await Promise.all([ - api.get(`/api/review/groups?bucket=inbox&offset=0&limit=25&sort=${sort}`), - api.get("/api/review/groups?bucket=review&offset=0&limit=25"), + api.get(`/api/review/groups?bucket=inbox&offset=0&limit=25&sort=${iSort}`), + api.get(`/api/review/groups?bucket=review&offset=0&limit=25&sort=${rSort}`), ]); setInboxInitial(inbox); setReviewInitial(review); @@ -104,8 +119,6 @@ export function PipelinePage() { } catch { /* ignore malformed events */ } - // Refresh columns progressively so items appear in their destination - // as processing runs (throttled to avoid hammering the server). schedulePipelineReload(); }); es.addEventListener("inbox_sorted", () => { @@ -155,15 +168,29 @@ export function PipelinePage() { readyCount={data.reviewReadyCount} manualCount={data.reviewManualCount} onMutate={loadAll} + sort={reviewSort} + onChangeSort={(next) => { + reviewSortRef.current = next; + setReviewSort(next); + loadGroups(); + }} /> - +
); diff --git a/src/features/pipeline/QueueColumn.tsx b/src/features/pipeline/QueueColumn.tsx index db1bc33..c82ed39 100644 --- a/src/features/pipeline/QueueColumn.tsx +++ b/src/features/pipeline/QueueColumn.tsx @@ -1,17 +1,34 @@ import { useEffect, useState } from "react"; import { api } from "~/shared/lib/api"; import type { PipelineJobItem } from "~/shared/lib/types"; -import { ColumnShell } from "./ColumnShell"; +import { ColumnShell, type SortOption } from "./ColumnShell"; import { PipelineCard } from "./PipelineCard"; +import type { JobSort } from "./PipelinePage"; + +const QUEUE_SORT_OPTIONS: SortOption[] = [ + { value: "added_asc", label: "↓ Added" }, + { value: "added_desc", label: "↑ Added" }, + { value: "name_asc", label: "↓ Name" }, + { value: "name_desc", label: "↑ Name" }, +]; interface QueueColumnProps { items: PipelineJobItem[]; autoProcessQueue: boolean; onToggleAutoProcessQueue: (enabled: boolean) => void; onMutate: () => void; + sort: JobSort; + onChangeSort: (next: JobSort) => void; } -export function QueueColumn({ items, autoProcessQueue, onToggleAutoProcessQueue, onMutate }: QueueColumnProps) { +export function QueueColumn({ + items, + autoProcessQueue, + onToggleAutoProcessQueue, + onMutate, + sort, + onChangeSort, +}: QueueColumnProps) { const [localEnabled, setLocalEnabled] = useState(autoProcessQueue); useEffect(() => { setLocalEnabled(autoProcessQueue); @@ -60,7 +77,16 @@ export function QueueColumn({ items, autoProcessQueue, onToggleAutoProcessQueue, ); return ( - + onChangeSort(v as JobSort)} + >
{items.map((item) => ( unapprove(item.item_id)} /> diff --git a/src/features/pipeline/ReviewColumn.tsx b/src/features/pipeline/ReviewColumn.tsx index 7de0c76..bdf5471 100644 --- a/src/features/pipeline/ReviewColumn.tsx +++ b/src/features/pipeline/ReviewColumn.tsx @@ -1,21 +1,41 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "~/shared/lib/api"; import type { ReviewGroup, ReviewGroupsResponse } from "~/shared/lib/types"; -import { ColumnShell } from "./ColumnShell"; +import { ColumnShell, type SortOption } from "./ColumnShell"; import { PipelineCard } from "./PipelineCard"; import { SeriesCard } from "./SeriesCard"; const PAGE_SIZE = 25; +export type ReviewSort = "class" | "scan_asc" | "scan_desc" | "name_asc" | "name_desc"; + +export const REVIEW_SORT_OPTIONS: SortOption[] = [ + { value: "class", label: "Classification" }, + { value: "scan_asc", label: "↓ Scan time" }, + { value: "scan_desc", label: "↑ Scan time" }, + { value: "name_asc", label: "↓ Name" }, + { value: "name_desc", label: "↑ Name" }, +]; + interface ReviewColumnProps { initialResponse: ReviewGroupsResponse; totalItems: number; readyCount: number; manualCount: number; onMutate: () => void; + sort: ReviewSort; + onChangeSort: (next: ReviewSort) => void; } -export function ReviewColumn({ initialResponse, totalItems, readyCount, manualCount, onMutate }: ReviewColumnProps) { +export function ReviewColumn({ + initialResponse, + totalItems, + readyCount, + manualCount, + onMutate, + sort, + onChangeSort, +}: ReviewColumnProps) { const [groups, setGroups] = useState(initialResponse.groups); const [hasMore, setHasMore] = useState(initialResponse.hasMore); const [loadingMore, setLoadingMore] = useState(false); @@ -31,7 +51,7 @@ export function ReviewColumn({ initialResponse, totalItems, readyCount, manualCo setLoadingMore(true); try { const res = await api.get( - `/api/review/groups?bucket=review&offset=${groups.length}&limit=${PAGE_SIZE}`, + `/api/review/groups?bucket=review&offset=${groups.length}&limit=${PAGE_SIZE}&sort=${sort}`, ); setGroups((prev) => [...prev, ...res.groups]); setHasMore(res.hasMore); @@ -117,6 +137,9 @@ export function ReviewColumn({ initialResponse, totalItems, readyCount, manualCo backward={backward} middle={skip} forward={forward} + sortOptions={REVIEW_SORT_OPTIONS} + sortValue={sort} + onSortChange={(v) => onChangeSort(v as ReviewSort)} >
{groups.map((group, index) => {