fix inbox sort during scan, move dropdown to button row, per-item Process button
Build and Push Docker Image / build (push) Successful in 48s
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:
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user