review column: 'approve above' on hover, wrap long audio titles
All checks were successful
Build and Push Docker Image / build (push) Successful in 55s
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:
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user