diff --git a/package.json b/package.json index 0f5acea..26e9410 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "netfelix-audio-fix", - "version": "2026.04.21.15", + "version": "2026.04.21.16", "scripts": { "dev:server": "NODE_ENV=development bun --hot server/index.tsx", "dev:client": "vite", diff --git a/server/api/execute.ts b/server/api/execute.ts index 079df15..a8e391a 100644 --- a/server/api/execute.ts +++ b/server/api/execute.ts @@ -63,7 +63,7 @@ function emitQueueStatus( for (const l of jobListeners) l(line); } -async function runSequential(initial: Job[]): Promise { +async function runSequential(initial: Job[], { drain = true } = {}): Promise { if (queueRunning) return; queueRunning = true; queueAbort = new AbortController(); @@ -115,8 +115,8 @@ async function runSequential(initial: Job[]): Promise { // When the local queue drains, re-check the DB for jobs that were // approved mid-run. Without this they'd sit pending until the user - // manually clicks "Run all" again. Skip if aborted — user wants to stop. - if (queue.length === 0 && !signal.aborted) { + // manually clicks "Run all" again. Skip if aborted or drain=false. + if (drain && queue.length === 0 && !signal.aborted) { const more = db.prepare("SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at").all() as Job[]; enqueueUnseenJobs(queue, seen, more); } @@ -253,7 +253,7 @@ app.post("/job/:id/run", async (c) => { if (!result) return c.notFound(); return c.json(result); } - runSequential([job]).catch((err) => logError(`Job ${job.id} failed:`, err)); + runSequential([job], { drain: false }).catch((err) => logError(`Job ${job.id} failed:`, err)); const result = loadJobRow(jobId); if (!result) return c.notFound(); return c.json(result); diff --git a/server/api/review.ts b/server/api/review.ts index c50c13c..1666240 100644 --- a/server/api/review.ts +++ b/server/api/review.ts @@ -526,7 +526,14 @@ function recomputePlanAfterToggle(db: ReturnType, itemId: number): } const isNoop = !anyAudioRemoved && !audioOrderChanged && !hasSubs && !needsTranscode; - db.prepare("UPDATE review_plans SET is_noop = ? WHERE id = ?").run(isNoop ? 1 : 0, plan.id); + + // Only flip is_noop to 1 when the plan is unsorted (inbox). If the user is + // actively reviewing a sorted plan, marking all tracks "keep" should NOT + // make the card vanish — the Review/Queue queries filter out noops. + const planRow = db.prepare("SELECT sorted FROM review_plans WHERE id = ?").get(plan.id) as { sorted: number }; + if (!isNoop || !planRow.sorted) { + db.prepare("UPDATE review_plans SET is_noop = ? WHERE id = ?").run(isNoop ? 1 : 0, plan.id); + } } // ─── Pipeline: summary ─────────────────────────────────────────────────────── @@ -865,7 +872,7 @@ app.get("/pipeline", (c) => { `) .all(); - const done = db + const doneJobs = db .prepare(` SELECT j.*, mi.name, mi.series_name, mi.type, rp.job_type, rp.apple_compat @@ -877,15 +884,21 @@ app.get("/pipeline", (c) => { `) .all(); - // "Done" = files already in the desired end state. Either the analyzer - // says nothing to do (is_noop=1) or a job finished. Use two indexable - // counts and add — the OR form (is_noop=1 OR status='done') can't use - // our single-column indexes and gets slow on large libraries. - const noopRow = db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1").get() as { n: number }; - const doneRow = db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'done' AND is_noop = 0").get() as { - n: number; - }; - const doneCount = noopRow.n + doneRow.n; + // Noop items (already in desired state, no job needed) also belong in Done. + const noopItems = db + .prepare(` + SELECT rp.item_id, mi.name, mi.series_name, mi.type, + rp.job_type, rp.apple_compat, rp.is_noop, + 'noop' as status + FROM review_plans rp + JOIN media_items mi ON mi.id = rp.item_id + WHERE rp.is_noop = 1 + ORDER BY mi.name + `) + .all(); + + const done = [...doneJobs, ...noopItems]; + const doneCount = done.length; enrichWithStreamsAndReasons(db, queued as EnrichableRow[]); diff --git a/src/features/pipeline/ColumnShell.tsx b/src/features/pipeline/ColumnShell.tsx index 55fa80d..4e93e82 100644 --- a/src/features/pipeline/ColumnShell.tsx +++ b/src/features/pipeline/ColumnShell.tsx @@ -24,6 +24,8 @@ interface ColumnShellProps { sortOptions?: SortOption[]; sortValue?: string; onSortChange?: (value: string) => void; + search?: string; + onSearchChange?: (value: string) => void; children: ReactNode; } @@ -62,6 +64,8 @@ export function ColumnShell({ sortOptions, sortValue, onSortChange, + search, + onSearchChange, children, }: ColumnShellProps) { return ( @@ -79,20 +83,30 @@ export function ColumnShell({
{forward && }
- {sortOptions && sortOptions.length > 0 && ( -
- Sort - + {(sortOptions?.length || onSearchChange) && ( +
+ {sortOptions && sortOptions.length > 0 && ( + + )} + {onSearchChange && ( + onSearchChange(e.target.value)} + className="h-5 text-[11px] border border-gray-300 rounded px-1.5 bg-white w-full text-gray-700 placeholder:text-gray-400" + /> + )}
)}
{children}
diff --git a/src/features/pipeline/DoneColumn.tsx b/src/features/pipeline/DoneColumn.tsx index 6a82828..ddcd336 100644 --- a/src/features/pipeline/DoneColumn.tsx +++ b/src/features/pipeline/DoneColumn.tsx @@ -1,4 +1,5 @@ import { Link } from "@tanstack/react-router"; +import { useState } from "react"; import { Badge } from "~/shared/components/ui/badge"; import { api } from "~/shared/lib/api"; import type { PipelineJobItem } from "~/shared/lib/types"; @@ -14,13 +15,20 @@ const DONE_SORT_OPTIONS: SortOption[] = [ interface DoneColumnProps { items: PipelineJobItem[]; - doneCount: number; onMutate: () => void; sort: JobSort; onChangeSort: (next: JobSort) => void; } -export function DoneColumn({ items, doneCount, onMutate, sort, onChangeSort }: DoneColumnProps) { +function statusVariant(status: string): "done" | "error" | "noop" { + if (status === "error") return "error"; + if (status === "noop") return "noop"; + return "done"; +} + +export function DoneColumn({ items, onMutate, sort, onChangeSort }: DoneColumnProps) { + const [search, setSearch] = useState(""); + const clear = async () => { await api.post("/api/execute/clear-completed"); onMutate(); @@ -54,15 +62,16 @@ export function DoneColumn({ items, doneCount, onMutate, sort, onChangeSort }: D onChangeSort(v as JobSort)} + search={search} + onSearchChange={setSearch} > - {items.map((item) => ( -
+ {items.filter((i) => !search || i.name.toLowerCase().includes(search.toLowerCase())).map((item) => ( +
))} diff --git a/src/features/pipeline/InboxColumn.tsx b/src/features/pipeline/InboxColumn.tsx index 2e006ef..9a795b3 100644 --- a/src/features/pipeline/InboxColumn.tsx +++ b/src/features/pipeline/InboxColumn.tsx @@ -42,6 +42,7 @@ export function InboxColumn({ const [loadingMore, setLoadingMore] = useState(false); const sentinelRef = useRef(null); + const [search, setSearch] = useState(""); const [localAutoProcessing, setLocalAutoProcessing] = useState(autoProcessing); useEffect(() => { setLocalAutoProcessing(autoProcessing); @@ -145,9 +146,16 @@ export function InboxColumn({ sortOptions={INBOX_SORT_OPTIONS} sortValue={sort} onSortChange={(v) => onChangeSort(v as InboxSort)} + search={search} + onSearchChange={setSearch} >
- {groups.map((group) => { + {groups.filter((g) => { + if (!search) return true; + const q = search.toLowerCase(); + if (g.kind === "movie") return g.item.name.toLowerCase().includes(q); + return g.seriesName.toLowerCase().includes(q); + }).map((group) => { if (group.kind === "movie") { return ( - {queueStatus && queueStatus.status !== "running" && ( + {queueStatus && queueStatus.status !== "running" && queueStatus.status !== "idle" && (
{queueStatus.status === "paused" && <>Paused until {queueStatus.until}} {queueStatus.status === "sleeping" && <>Next job in {sleepRemaining ?? queueStatus.seconds ?? 0}s} - {queueStatus.status === "idle" && <>Idle}
)} diff --git a/src/features/pipeline/QueueColumn.tsx b/src/features/pipeline/QueueColumn.tsx index e8ac98a..cdfbc3e 100644 --- a/src/features/pipeline/QueueColumn.tsx +++ b/src/features/pipeline/QueueColumn.tsx @@ -29,6 +29,7 @@ export function QueueColumn({ sort, onChangeSort, }: QueueColumnProps) { + const [search, setSearch] = useState(""); const [localEnabled, setLocalEnabled] = useState(autoProcessQueue); useEffect(() => { setLocalEnabled(autoProcessQueue); @@ -91,10 +92,12 @@ export function QueueColumn({ sortOptions={QUEUE_SORT_OPTIONS} sortValue={sort} onSortChange={(v) => onChangeSort(v as JobSort)} + search={search} + onSearchChange={setSearch} >
- {items.map((item) => ( - unapprove(item.item_id)} onRun={() => runSingle(item.id)} /> + {items.filter((i) => !search || i.name.toLowerCase().includes(search.toLowerCase())).map((item) => ( + unapprove(item.item_id)} onRun={() => runSingle(item.id!)} /> ))} {items.length === 0 &&

Queue empty

}
diff --git a/src/features/pipeline/ReviewColumn.tsx b/src/features/pipeline/ReviewColumn.tsx index ff732a6..1d81905 100644 --- a/src/features/pipeline/ReviewColumn.tsx +++ b/src/features/pipeline/ReviewColumn.tsx @@ -36,6 +36,7 @@ export function ReviewColumn({ sort, onChangeSort, }: ReviewColumnProps) { + const [search, setSearch] = useState(""); const [groups, setGroups] = useState(initialResponse.groups); const [hasMore, setHasMore] = useState(initialResponse.hasMore); const [loadingMore, setLoadingMore] = useState(false); @@ -144,9 +145,16 @@ export function ReviewColumn({ sortOptions={REVIEW_SORT_OPTIONS} sortValue={sort} onSortChange={(v) => onChangeSort(v as ReviewSort)} + search={search} + onSearchChange={setSearch} >
- {groups.map((group, index) => { + {groups.filter((g) => { + if (!search) return true; + const q = search.toLowerCase(); + if (g.kind === "movie") return g.item.name.toLowerCase().includes(q); + return g.seriesName.toLowerCase().includes(q); + }).map((group, index) => { const prior = index > 0 ? priorIds(index) : null; const onApproveUpToHere = prior && prior.length > 0 ? () => approveBatch(prior) : undefined; if (group.kind === "movie") { diff --git a/src/shared/lib/types.ts b/src/shared/lib/types.ts index 3be8953..4867567 100644 --- a/src/shared/lib/types.ts +++ b/src/shared/lib/types.ts @@ -120,14 +120,16 @@ export interface PipelineAudioStream { action: "keep" | "remove"; } -/** Row in the Queued / Processing / Done columns: job joined with media_item + review_plan. */ +/** Row in the Queued / Processing / Done columns: job joined with media_item + review_plan. + * Noop items (already in desired state) appear in Done without a job, so + * job-specific fields (id, started_at, completed_at) are optional. */ export interface PipelineJobItem { - id: number; + id?: number; item_id: number; - status: Job["status"]; - job_type: "copy" | "transcode"; - started_at: string | null; - completed_at: string | null; + status: Job["status"] | "noop"; + job_type?: "copy" | "transcode"; + started_at?: string | null; + completed_at?: string | null; name: string; series_name: string | null; type: "Movie" | "Episode";