fix inbox sort during scan, move dropdown to button row, per-item Process button
Build and Push Docker Image / build (push) Successful in 48s

- sort state lifted to PipelinePage so loadGroups includes the sort param
  on every reload (scan SSE events no longer reset the sort)
- sort dropdown moved from subtitle to ColumnShell middle slot (left of
  Process Inbox button)
- ColumnShell.skip renamed to middle, accepts ReactNode or ColumnAction
- per-item "Process →" button on inbox movie cards and series cards:
  POST /:id/process resolves language + reanalyzes + sorts a single item
- dashboard stat pills refresh during scan (every 25 items)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 09:18:52 +02:00
parent 6325bdb3e9
commit 789a9f7bfe
10 changed files with 1576 additions and 60 deletions
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "netfelix-audio-fix",
"version": "2026.04.21.3",
"version": "2026.04.21.4",
"scripts": {
"dev:server": "NODE_ENV=development bun --hot server/index.tsx",
"dev:client": "vite",
+59
View File
@@ -1123,6 +1123,65 @@ app.post("/process-inbox/stop", (c) => {
return c.json({ ok: true, message: "not running" });
});
// ─── Process single item ────────────────────────────────────────────────────
// Runs language resolution + reanalysis + sort for one inbox item.
app.post("/:id/process", async (c) => {
const db = getDb();
const id = parseId(c.req.param("id"));
if (id == null) return c.json({ error: "invalid id" }, 400);
const plan = db
.prepare("SELECT id FROM review_plans WHERE item_id = ? AND status = 'pending' AND sorted = 0")
.get(id) as { id: number } | undefined;
if (!plan) return c.json({ error: "item not in inbox" }, 404);
// Build language resolver (same as processInbox)
const cfg = getAllConfig();
const radarrCfg = { url: cfg.radarr_url, apiKey: cfg.radarr_api_key };
const sonarrCfg = { url: cfg.sonarr_url, apiKey: cfg.sonarr_api_key };
const radarrEnabled = cfg.radarr_enabled === "1" && radarrUsable(radarrCfg);
const sonarrEnabled = cfg.sonarr_enabled === "1" && sonarrUsable(sonarrCfg);
const [radarrLibrary, sonarrLibrary] = await Promise.all([
radarrEnabled ? loadRadarrLibrary(radarrCfg) : Promise.resolve(null),
sonarrEnabled ? loadSonarrLibrary(sonarrCfg) : Promise.resolve(null),
]);
const resolverCfg: LanguageResolverConfig = {
radarr: radarrEnabled ? radarrCfg : null,
sonarr: sonarrEnabled ? sonarrCfg : null,
radarrLibrary,
sonarrLibrary,
};
// Resolve language
const langResult = await resolveLanguage(db, id, resolverCfg);
if (langResult.externalRaw != null) {
db
.prepare("UPDATE media_items SET original_language = ?, orig_lang_source = ?, needs_review = ? WHERE id = ?")
.run(langResult.origLang, langResult.origLangSource, langResult.needsReview, id);
}
// Reanalyze + sort
const audioLanguages = getAudioLanguages();
reanalyze(db, id, audioLanguages);
const updated = db.prepare("SELECT auto_class, is_noop FROM review_plans WHERE item_id = ?").get(id) as
| { auto_class: string | null; is_noop: number }
| undefined;
if (updated && !updated.is_noop) {
if (updated.auto_class === "auto") {
db
.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now'), sorted = 1 WHERE id = ?")
.run(plan.id);
const { item, streams, decisions } = loadItemDetail(db, id);
if (item) enqueueAudioJob(db, id, buildCommand(item, streams, decisions));
} else {
db.prepare("UPDATE review_plans SET sorted = 1 WHERE id = ?").run(plan.id);
}
}
emitPipelineChanged();
return c.json({ ok: true, destination: updated?.auto_class === "auto" ? "queue" : "review" });
});
// ─── Approve all ready ───────────────────────────────────────────────────────
// Bulk-approves every auto_heuristic-classified plan currently in Review.
app.post("/approve-ready", (c) => {
+10 -3
View File
@@ -14,7 +14,8 @@ interface ColumnShellProps {
count: ReactNode;
subtitle?: ReactNode;
backward?: ColumnAction;
skip?: ColumnAction;
/** Middle slot: accepts a ColumnAction button or any ReactNode (e.g. a dropdown). */
middle?: ColumnAction | ReactNode;
forward?: ColumnAction;
children: ReactNode;
}
@@ -40,6 +41,10 @@ function ActionButton({ action }: { action: ColumnAction }) {
);
}
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
@@ -51,7 +56,7 @@ function ActionButton({ action }: { action: ColumnAction }) {
* same height; buttons passed as disabled still occupy their slot so the
* header never jumps between states.
*/
export function ColumnShell({ title, count, subtitle, backward, skip, forward, children }: ColumnShellProps) {
export function ColumnShell({ title, count, subtitle, backward, middle, forward, children }: ColumnShellProps) {
return (
<div className="flex flex-col flex-1 basis-0 min-w-80 min-h-0 bg-gray-50 rounded-lg">
<div className="flex flex-col gap-1.5 px-3 py-2 border-b">
@@ -66,7 +71,9 @@ export function ColumnShell({ title, count, subtitle, backward, skip, forward, c
*/}
<div className="grid grid-cols-[auto_1fr_auto] items-center gap-1 min-h-[1.375rem]">
<div className="flex justify-start">{backward && <ActionButton action={backward} />}</div>
<div className="flex justify-center">{skip && <ActionButton action={skip} />}</div>
<div className="flex justify-center">
{middle && (isColumnAction(middle) ? <ActionButton action={middle} /> : middle)}
</div>
<div className="flex justify-end">{forward && <ActionButton action={forward} />}</div>
</div>
</div>
+46 -27
View File
@@ -23,6 +23,8 @@ interface InboxColumnProps {
onToggleAutoProcessing: (enabled: boolean) => void;
onMutate: () => void;
sortProgress: { processed: number; total: number } | null;
sort: InboxSort;
onChangeSort: (next: InboxSort) => void;
}
export function InboxColumn({
@@ -32,11 +34,12 @@ export function InboxColumn({
onToggleAutoProcessing,
onMutate,
sortProgress,
sort,
onChangeSort,
}: InboxColumnProps) {
const [groups, setGroups] = useState<ReviewGroup[]>(initialResponse.groups);
const [hasMore, setHasMore] = useState(initialResponse.hasMore);
const [loadingMore, setLoadingMore] = useState(false);
const [sort, setSort] = useState<InboxSort>("scan_asc");
const sentinelRef = useRef<HTMLDivElement | null>(null);
// Optimistic mirror of the auto-process checkbox so a click flips the
@@ -52,15 +55,6 @@ export function InboxColumn({
setHasMore(initialResponse.hasMore);
}, [initialResponse]);
const changeSort = useCallback(async (next: InboxSort) => {
setSort(next);
const res = await api.get<ReviewGroupsResponse>(
`/api/review/groups?bucket=inbox&offset=0&limit=${PAGE_SIZE}&sort=${next}`,
);
setGroups(res.groups);
setHasMore(res.hasMore);
}, []);
const loadMore = useCallback(async () => {
if (loadingMore || !hasMore) return;
setLoadingMore(true);
@@ -95,6 +89,11 @@ export function InboxColumn({
await api.post("/api/review/process-inbox/stop");
};
const processItem = async (itemId: number) => {
await api.post(`/api/review/${itemId}/process`);
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
@@ -114,7 +113,6 @@ export function InboxColumn({
</div>
</div>
) : (
<div className="flex items-center gap-3 w-full">
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
@@ -126,24 +124,13 @@ export function InboxColumn({
onToggleAutoProcessing(next);
}}
/>
<span>Auto-process</span>
<span>Auto-process Inbox</span>
</label>
<div className="flex-1" />
<select
className="h-5 text-[11px] border border-gray-300 rounded px-1 bg-white"
value={sort}
onChange={(e) => changeSort(e.target.value as InboxSort)}
>
{SORT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
);
const backward = sorting ? { label: "Stop Sorting", onClick: stopProcess, danger: true } : undefined;
// Sort dropdown + Process Inbox button share the forward slot
const forward = sorting
? undefined
: {
@@ -154,12 +141,39 @@ export function InboxColumn({
title: "Process inbox to Queue / Review",
};
const sortDropdown = !sorting ? (
<select
className="h-5 text-[11px] border border-gray-300 rounded px-1 bg-white"
value={sort}
onChange={(e) => onChangeSort(e.target.value as InboxSort)}
>
{SORT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : undefined;
return (
<ColumnShell title="Inbox" count={totalItems} subtitle={subtitle} backward={backward} forward={forward}>
<ColumnShell
title="Inbox"
count={totalItems}
subtitle={subtitle}
backward={backward}
middle={sortDropdown}
forward={forward}
>
<div className="space-y-2">
{groups.map((group) => {
if (group.kind === "movie") {
return <PipelineCard key={group.item.id} item={group.item} />;
return (
<PipelineCard
key={group.item.id}
item={group.item}
onProcess={() => processItem(group.item.item_id ?? (group.item as { id: number }).id)}
/>
);
}
return (
<SeriesCard
@@ -171,6 +185,11 @@ export function InboxColumn({
readyCount={group.readyCount}
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());
}}
/>
);
})}
+14 -1
View File
@@ -58,6 +58,8 @@ interface PipelineCardProps {
// expanded series episodes don't get this (the series' "Approve all"
// covers the prior-episodes-in-series case).
onApproveUpToHere?: () => void;
// Inbox: process this single item (resolve language + classify → Review/Queue).
onProcess?: () => void;
}
function formatChannels(n: number | null | undefined): string | null {
@@ -86,6 +88,7 @@ export function PipelineCard({
onSkip,
onUnapprove,
onApproveUpToHere,
onProcess,
}: PipelineCardProps) {
const title =
item.type === "Episode"
@@ -104,7 +107,7 @@ export function PipelineCard({
// media_item rows (no plan) in which case we fall back to item.id.
const mediaItemId: number = item.item_id ?? (item as { id: number }).id;
const hasActionRow = !!(onSkip || onApprove || onUnapprove || onApproveUpToHere);
const hasActionRow = !!(onSkip || onApprove || onUnapprove || onApproveUpToHere || onProcess);
const hasTranscodeReasons = !!item.transcode_reasons && item.transcode_reasons.length > 0;
const hasInfoRow = hasTranscodeReasons || item.job_type === "copy" || !!item.auto_class;
@@ -153,6 +156,16 @@ export function PipelineCard({
Back to review
</button>
)}
{onProcess && (
<button
type="button"
onClick={onProcess}
title="Classify and move to Review/Queue"
className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700"
>
Process
</button>
)}
</div>
)}
+3
View File
@@ -116,6 +116,9 @@ export function PipelineHeader() {
setErrors(b.errors);
setCurrentItem(b.currentItem);
b.dirty = false;
// Refresh dashboard stats periodically so the stat pills update
// during a scan (every ~5s to avoid hammering the server).
if (b.scanned % 25 === 0) loadStats();
}
if (b.complete) {
+12 -1
View File
@@ -25,6 +25,8 @@ interface SortProgress {
total: number;
}
type InboxSort = "scan_asc" | "scan_desc" | "name_asc" | "name_desc";
export function PipelinePage() {
const [data, setData] = useState<PipelineData | null>(null);
const [inboxInitial, setInboxInitial] = useState<ReviewGroupsResponse | null>(null);
@@ -33,6 +35,8 @@ export function PipelinePage() {
const [queueStatus, setQueueStatus] = useState<QueueStatus | null>(null);
const [sortProgress, setSortProgress] = useState<SortProgress | null>(null);
const [loading, setLoading] = useState(true);
const [inboxSort, setInboxSort] = useState<InboxSort>("scan_asc");
const inboxSortRef = useRef<InboxSort>("scan_asc");
const loadPipeline = useCallback(async () => {
const res = await api.get<PipelineData>("/api/review/pipeline");
@@ -40,8 +44,9 @@ export function PipelinePage() {
}, []);
const loadGroups = useCallback(async () => {
const sort = inboxSortRef.current;
const [inbox, review] = await Promise.all([
api.get<ReviewGroupsResponse>("/api/review/groups?bucket=inbox&offset=0&limit=25"),
api.get<ReviewGroupsResponse>(`/api/review/groups?bucket=inbox&offset=0&limit=25&sort=${sort}`),
api.get<ReviewGroupsResponse>("/api/review/groups?bucket=review&offset=0&limit=25"),
]);
setInboxInitial(inbox);
@@ -137,6 +142,12 @@ export function PipelinePage() {
onToggleAutoProcessing={toggleAutoProcessing}
onMutate={loadAll}
sortProgress={sortProgress}
sort={inboxSort}
onChangeSort={(next) => {
inboxSortRef.current = next;
setInboxSort(next);
loadGroups();
}}
/>
<ReviewColumn
initialResponse={reviewInitial}
+8 -1
View File
@@ -110,7 +110,14 @@ export function ReviewColumn({ initialResponse, totalItems, readyCount, manualCo
const subtitle = totalItems === 0 ? undefined : `${readyCount} auto · ${manualCount} need decisions`;
return (
<ColumnShell title="Review" count={totalItems} subtitle={subtitle} backward={backward} skip={skip} forward={forward}>
<ColumnShell
title="Review"
count={totalItems}
subtitle={subtitle}
backward={backward}
middle={skip}
forward={forward}
>
<div className="space-y-2">
{groups.map((group, index) => {
const prior = index > 0 ? priorIds(index) : null;
+18
View File
@@ -15,6 +15,8 @@ interface SeriesCardProps {
// Review-column affordance: approve every card visually above this
// series in one round-trip. See ReviewColumn for the id computation.
onApproveUpToHere?: () => void;
// Inbox: process entire series (resolve language + classify → Review/Queue).
onProcess?: () => void;
}
export function SeriesCard({
@@ -26,6 +28,7 @@ export function SeriesCard({
originalLanguage,
onMutate,
onApproveUpToHere,
onProcess,
}: SeriesCardProps) {
const [expanded, setExpanded] = useState(false);
const [rescanning, setRescanning] = useState(false);
@@ -88,6 +91,20 @@ export function SeriesCard({
>
</button>
{onProcess && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onProcess();
}}
title="Classify and move to Review/Queue"
className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 cursor-pointer shrink-0"
>
Process
</button>
)}
{!onProcess && (
<button
type="button"
onClick={(e) => {
@@ -102,6 +119,7 @@ export function SeriesCard({
<span className="-ml-1"></span>
</span>
</button>
)}
</div>
{/* Title row */}