review column: 'approve above' on hover, wrap long audio titles
All checks were successful
Build and Push Docker Image / build (push) Successful in 55s

each top-level card now shows a secondary button on hover ('↑ approve
above') that approves every card listed above this one in one
round-trip. uses a new POST /api/review/approve-batch { itemIds } that
ignores non-pending items so stale client state can't 409. series cards
get the same affordance scoped via a named tailwind group so it
doesn't collide with the inner episode cards' own hover state.

fix the horizontal-scroll glitch: long unbreakable audio titles (e.g.
the raw release filename) now line-wrap inside the card via
[overflow-wrap:anywhere] + min-w-0 on the span. previously
break-words was a no-op since there were no whitespace break points
in the release string.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 18:11:57 +02:00
parent 1de5b8a89e
commit 47781e04f9
5 changed files with 102 additions and 6 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "netfelix-audio-fix",
"version": "2026.04.14.17",
"version": "2026.04.14.18",
"scripts": {
"dev:server": "NODE_ENV=development bun --hot server/index.tsx",
"dev:client": "vite",

View File

@@ -575,6 +575,44 @@ app.post("/approve-all", (c) => {
return c.json({ ok: true, count: pending.length });
});
// ─── Batch approve (by item id list) ─────────────────────────────────────────
// Used by the "approve up to here" affordance in the review column. The
// client knows the visible order (movies + series sort-key) and passes in
// the prefix of item ids it wants approved in one round-trip. Items that
// aren't pending (already approved / skipped / done) are silently ignored
// so the endpoint is idempotent against stale client state.
app.post("/approve-batch", async (c) => {
const db = getDb();
const body = await c.req.json<{ itemIds?: unknown }>().catch(() => ({ itemIds: undefined }));
if (
!Array.isArray(body.itemIds) ||
!body.itemIds.every((v) => typeof v === "number" && Number.isInteger(v) && v > 0)
) {
return c.json({ ok: false, error: "itemIds must be an array of positive integers" }, 400);
}
const ids = body.itemIds as number[];
if (ids.length === 0) return c.json({ ok: true, count: 0 });
const placeholders = ids.map(() => "?").join(",");
const pending = db
.prepare(
`SELECT rp.*, mi.id as item_id FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id
WHERE rp.status = 'pending' AND rp.is_noop = 0 AND mi.id IN (${placeholders})`,
)
.all(...ids) as (ReviewPlan & { item_id: number })[];
let count = 0;
for (const plan of pending) {
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
const { item, streams, decisions } = loadItemDetail(db, plan.item_id);
if (item) {
enqueueAudioJob(db, plan.item_id, buildCommand(item, streams, decisions));
count++;
}
}
return c.json({ ok: true, count });
});
// ─── Auto-approve high-confidence ────────────────────────────────────────────
// Approves every pending plan whose original language came from an authoritative
// source (radarr/sonarr). Anything with low confidence keeps needing a human.

View File

@@ -32,6 +32,11 @@ interface PipelineCardProps {
// (no onToggleStream) and the primary button un-approves the plan,
// sending the item back to the Review column.
onUnapprove?: () => void;
// Review-column affordance: approve this card AND every card visually
// above it in one round-trip. Only set for the top-level review list;
// expanded series episodes don't get this (the series' "Approve all"
// covers the prior-episodes-in-series case).
onApproveUpToHere?: () => void;
}
function formatChannels(n: number | null | undefined): string | null {
@@ -52,7 +57,15 @@ function describeStream(s: PipelineAudioStream): string {
return parts.join(" · ");
}
export function PipelineCard({ item, jellyfinUrl, onToggleStream, onApprove, onSkip, onUnapprove }: PipelineCardProps) {
export function PipelineCard({
item,
jellyfinUrl,
onToggleStream,
onApprove,
onSkip,
onUnapprove,
onApproveUpToHere,
}: PipelineCardProps) {
const title =
item.type === "Episode"
? `S${String(item.season_number).padStart(2, "0")}E${String(item.episode_number).padStart(2, "0")}${item.name}`
@@ -68,7 +81,7 @@ export function PipelineCard({ item, jellyfinUrl, onToggleStream, onApprove, onS
const mediaItemId: number = item.item_id ?? (item as { id: number }).id;
return (
<div className={`rounded-lg border p-3 ${confidenceColor}`}>
<div className={`group rounded-lg border p-3 ${confidenceColor}`}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
{jellyfinLink ? (
@@ -124,7 +137,7 @@ export function PipelineCard({ item, jellyfinUrl, onToggleStream, onApprove, onS
{description && <span>{description}</span>}
{s.is_default === 1 && <span className="text-[10px] text-gray-400 uppercase">default</span>}
{s.title && (
<span className="text-gray-400 break-words" title={s.title}>
<span className="text-gray-400 min-w-0 [overflow-wrap:anywhere]" title={s.title}>
{s.title}
</span>
)}
@@ -157,6 +170,16 @@ export function PipelineCard({ item, jellyfinUrl, onToggleStream, onApprove, onS
</button>
)}
<div className="flex-1" />
{onApproveUpToHere && (
<button
type="button"
onClick={onApproveUpToHere}
title="Approve every card listed above this one"
className="text-xs px-2 py-1 rounded border border-blue-600 text-blue-700 bg-white hover:bg-blue-50 opacity-0 group-hover:opacity-100 transition-opacity"
>
Approve above
</button>
)}
{onApprove && (
<button
type="button"

View File

@@ -41,6 +41,11 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu
await api.post(`/api/review/${itemId}/skip`);
onMutate();
};
const approveBatch = async (itemIds: number[]) => {
if (itemIds.length === 0) return;
await api.post<{ ok: boolean; count: number }>("/api/review/approve-batch", { itemIds });
onMutate();
};
// Group by series (movies are standalone)
const movies = items.filter((i) => i.type === "Movie");
@@ -64,6 +69,14 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu
})),
].sort((a, b) => a.sortKey - b.sortKey);
// Flatten each visible entry to its list of item_ids. "Approve up to here"
// on index i approves everything in the union of idsByEntry[0..i-1] — one
// id for a movie, N ids for a series (one per episode).
const idsByEntry: number[][] = allItems.map((entry) =>
entry.type === "movie" ? [entry.item.item_id] : entry.item.episodes.map((e) => e.item_id),
);
const priorIds = (index: number): number[] => idsByEntry.slice(0, index).flat();
return (
<ColumnShell
title="Review"
@@ -78,7 +91,11 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu
}
>
<div className="space-y-2">
{allItems.map((entry) => {
{allItems.map((entry, index) => {
// The button approves everything visually above this card. First
// card has nothing before it → undefined suppresses the affordance.
const prior = index > 0 ? priorIds(index) : null;
const onApproveUpToHere = prior && prior.length > 0 ? () => approveBatch(prior) : undefined;
if (entry.type === "movie") {
return (
<PipelineCard
@@ -91,6 +108,7 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu
}}
onApprove={() => approveItem(entry.item.item_id)}
onSkip={() => skipItem(entry.item.item_id)}
onApproveUpToHere={onApproveUpToHere}
/>
);
}
@@ -103,6 +121,7 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu
seriesJellyfinId={entry.item.jellyfinId}
episodes={entry.item.episodes}
onMutate={onMutate}
onApproveUpToHere={onApproveUpToHere}
/>
);
})}

View File

@@ -11,6 +11,9 @@ interface SeriesCardProps {
seriesJellyfinId: string | null;
episodes: PipelineReviewItem[];
onMutate: () => void;
// Review-column affordance: approve every card visually above this
// series in one round-trip. See ReviewColumn for the id computation.
onApproveUpToHere?: () => void;
}
export function SeriesCard({
@@ -20,6 +23,7 @@ export function SeriesCard({
seriesJellyfinId,
episodes,
onMutate,
onApproveUpToHere,
}: SeriesCardProps) {
const [expanded, setExpanded] = useState(false);
@@ -42,7 +46,7 @@ export function SeriesCard({
jellyfinUrl && seriesJellyfinId ? `${jellyfinUrl}/web/index.html#!/details?id=${seriesJellyfinId}` : null;
return (
<div className="group rounded-lg border bg-white overflow-hidden">
<div className="group/series rounded-lg border bg-white overflow-hidden">
{/* Title row */}
<div
className="flex items-center gap-2 px-3 pt-3 pb-1 cursor-pointer hover:bg-gray-50 rounded-t-lg"
@@ -85,6 +89,18 @@ export function SeriesCard({
</option>
))}
</select>
{onApproveUpToHere && (
<button
onClick={(e) => {
e.stopPropagation();
onApproveUpToHere();
}}
title="Approve every card listed above this one"
className="text-xs px-2 py-1 rounded border border-blue-600 text-blue-700 bg-white hover:bg-blue-50 cursor-pointer whitespace-nowrap shrink-0 opacity-0 group-hover/series:opacity-100 transition-opacity"
>
Approve above
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();