From e3b241bef3c2805600c9782ece48f6d0a312202c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Mon, 13 Apr 2026 11:20:57 +0200 Subject: [PATCH] drop audio list tab, move per-item actions onto pipeline cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pipeline tab fully replaces the audio list: same items, better workflow. What the old list contributed (per-item details + skip/approve) now lives inline on each pipeline card. - delete src/routes/review/audio/index.tsx + src/features/review/AudioListPage.tsx - /review/ now redirects to /pipeline (was /review/audio, which no longer exists) - AudioDetailPage back link goes to /pipeline - nav: drop the Audio link - PipelineCard: three buttons on every card — Details (TanStack Link to /review/audio/$id — the detail route stays, it's how you drill in), Skip (POST /api/review/:id/skip), Approve (POST /api/review/:id/approve). Remove the old 'Approve up to here' button (it was computing against frontend ordering we don't want to maintain, and it was broken). - SeriesCard: drop onApproveUpTo, pass new approve/skip handlers through to each expanded episode card - server: remove now-unused POST /api/review/approve-batch (no callers) --- server/api/review.ts | 42 --- src/features/pipeline/PipelineCard.tsx | 46 ++- src/features/pipeline/ReviewColumn.tsx | 56 ++- src/features/pipeline/SeriesCard.tsx | 21 +- src/features/review/AudioDetailPage.tsx | 4 +- src/features/review/AudioListPage.tsx | 468 ------------------------ src/routes/__root.tsx | 1 - src/routes/review/audio/index.tsx | 10 - src/routes/review/index.tsx | 2 +- 9 files changed, 70 insertions(+), 580 deletions(-) delete mode 100644 src/features/review/AudioListPage.tsx delete mode 100644 src/routes/review/audio/index.tsx diff --git a/server/api/review.ts b/server/api/review.ts index d03f250..7609400 100644 --- a/server/api/review.ts +++ b/server/api/review.ts @@ -734,48 +734,6 @@ app.post("/:id/rescan", async (c) => { return c.json(detail); }); -// ─── Pipeline: approve a batch of plan IDs ────────────────────────────────── -// -// The pipeline UI groups episodes into series cards and interleaves them -// with movies in a frontend-specific order, so we can't reconstruct -// "up to here" by re-running an ORDER BY on the server. The client knows -// exactly which plans are visually before (and including) the clicked card -// and sends them as an explicit list. - -app.post("/approve-batch", async (c) => { - const body = await c.req.json<{ planIds: unknown }>().catch(() => ({ planIds: null })); - if (!Array.isArray(body.planIds) || !body.planIds.every((id) => typeof id === "number" && id > 0)) { - return c.json({ error: "planIds must be an array of positive integers" }, 400); - } - const planIds = body.planIds as number[]; - if (planIds.length === 0) return c.json({ approved: 0 }); - const db = getDb(); - const toApprove = planIds; - - // Only approve plans that are still pending and not noop. Skip silently - // if a plan was already approved/skipped or doesn't exist — keeps batch - // idempotent under concurrent edits. - let approved = 0; - for (const planId of toApprove) { - const planRow = db - .prepare( - "SELECT id, item_id, status, is_noop, job_type FROM review_plans WHERE id = ? AND status = 'pending' AND is_noop = 0", - ) - .get(planId) as { id: number; item_id: number; job_type: string } | undefined; - if (!planRow) continue; - db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(planId); - const detail = loadItemDetail(db, planRow.item_id); - if (detail.item && detail.command) { - db - .prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, ?, 'pending')") - .run(planRow.item_id, detail.command, planRow.job_type); - approved++; - } - } - - return c.json({ approved }); -}); - // ─── Pipeline: series language ─────────────────────────────────────────────── app.patch("/series/:seriesKey/language", async (c) => { diff --git a/src/features/pipeline/PipelineCard.tsx b/src/features/pipeline/PipelineCard.tsx index 98687f4..82f639c 100644 --- a/src/features/pipeline/PipelineCard.tsx +++ b/src/features/pipeline/PipelineCard.tsx @@ -1,3 +1,4 @@ +import { Link } from "@tanstack/react-router"; import { Badge } from "~/shared/components/ui/badge"; import { LANG_NAMES, langName } from "~/shared/lib/lang"; @@ -5,10 +6,11 @@ interface PipelineCardProps { item: any; jellyfinUrl: string; onLanguageChange?: (lang: string) => void; - onApproveUpTo?: () => void; + onApprove?: () => void; + onSkip?: () => void; } -export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApproveUpTo }: PipelineCardProps) { +export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApprove, onSkip }: PipelineCardProps) { const title = item.type === "Episode" ? `S${String(item.season_number).padStart(2, "0")}E${String(item.episode_number).padStart(2, "0")} — ${item.name}` @@ -19,8 +21,12 @@ export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApproveUpT const jellyfinLink = jellyfinUrl && item.jellyfin_id ? `${jellyfinUrl}/web/index.html#!/details?id=${item.jellyfin_id}` : null; + // item.item_id is present in pipeline payloads; card can also be fed raw + // media_item rows (no plan) in which case we fall back to item.id. + const mediaItemId: number = item.item_id ?? item.id; + return ( -
+
{jellyfinLink ? ( @@ -65,14 +71,34 @@ export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApproveUpT
- {onApproveUpTo && ( - - )} + Details + + {onSkip && ( + + )} +
+ {onApprove && ( + + )} +
); } diff --git a/src/features/pipeline/ReviewColumn.tsx b/src/features/pipeline/ReviewColumn.tsx index beb1eed..0bdd6ef 100644 --- a/src/features/pipeline/ReviewColumn.tsx +++ b/src/features/pipeline/ReviewColumn.tsx @@ -18,6 +18,16 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu await api.post("/api/review/skip-all"); onMutate(); }; + + const approveItem = async (itemId: number) => { + await api.post(`/api/review/${itemId}/approve`); + onMutate(); + }; + const skipItem = async (itemId: number) => { + await api.post(`/api/review/${itemId}/skip`); + onMutate(); + }; + // Group by series (movies are standalone) const movies = items.filter((i: any) => i.type === "Movie"); const seriesMap = new Map(); @@ -40,25 +50,6 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu })), ].sort((a, b) => a.sortKey - b.sortKey); - /** All plan IDs the user can see, in visible order — the source of truth for "up to here". */ - const visiblePlanIds: number[] = allItems.flatMap((entry) => - entry.type === "movie" ? [entry.item.id] : entry.item.episodes.map((e: any) => e.id), - ); - - /** Approve every visible plan from the top through (and including) the given index. */ - const approveUpToIndex = async (visibleIndex: number) => { - const planIds = visiblePlanIds.slice(0, visibleIndex + 1); - if (planIds.length === 0) return; - await api.post("/api/review/approve-batch", { planIds }); - onMutate(); - }; - - /** Index of the last plan in this entry within the visible list — used as the "up to" boundary. */ - const lastVisibleIndex = (entry: (typeof allItems)[number]): number => { - const lastId = entry.type === "movie" ? entry.item.id : entry.item.episodes[entry.item.episodes.length - 1]?.id; - return visiblePlanIds.lastIndexOf(lastId); - }; - return ( approveUpToIndex(lastVisibleIndex(entry))} - /> - ); - } else { - return ( - approveUpToIndex(lastVisibleIndex(entry))} + onApprove={() => approveItem(entry.item.item_id)} + onSkip={() => skipItem(entry.item.item_id)} /> ); } + return ( + + ); })} {allItems.length === 0 &&

No items to review

} {truncated && ( diff --git a/src/features/pipeline/SeriesCard.tsx b/src/features/pipeline/SeriesCard.tsx index 153a563..865d10e 100644 --- a/src/features/pipeline/SeriesCard.tsx +++ b/src/features/pipeline/SeriesCard.tsx @@ -10,7 +10,6 @@ interface SeriesCardProps { seriesJellyfinId: string | null; episodes: any[]; onMutate: () => void; - onApproveUpTo?: () => void; } export function SeriesCard({ @@ -20,7 +19,6 @@ export function SeriesCard({ seriesJellyfinId, episodes, onMutate, - onApproveUpTo, }: SeriesCardProps) { const [expanded, setExpanded] = useState(false); @@ -97,17 +95,6 @@ export function SeriesCard({
- {onApproveUpTo && ( -
- -
- )} - {expanded && (
{episodes.map((ep: any) => ( @@ -119,6 +106,14 @@ export function SeriesCard({ await api.patch(`/api/review/${ep.item_id}/language`, { language: lang }); onMutate(); }} + onApprove={async () => { + await api.post(`/api/review/${ep.item_id}/approve`); + onMutate(); + }} + onSkip={async () => { + await api.post(`/api/review/${ep.item_id}/skip`); + onMutate(); + }} /> ))}
diff --git a/src/features/review/AudioDetailPage.tsx b/src/features/review/AudioDetailPage.tsx index 4299e66..7977d7e 100644 --- a/src/features/review/AudioDetailPage.tsx +++ b/src/features/review/AudioDetailPage.tsx @@ -266,8 +266,8 @@ export function AudioDetailPage() {

- - ← Audio + + ← Pipeline {item.name}

diff --git a/src/features/review/AudioListPage.tsx b/src/features/review/AudioListPage.tsx deleted file mode 100644 index 3a7169b..0000000 --- a/src/features/review/AudioListPage.tsx +++ /dev/null @@ -1,468 +0,0 @@ -import { Link, useNavigate, useSearch } from "@tanstack/react-router"; -import { useEffect, useState } from "react"; -import { Badge } from "~/shared/components/ui/badge"; -import { Button } from "~/shared/components/ui/button"; -import { FilterTabs } from "~/shared/components/ui/filter-tabs"; -import { api } from "~/shared/lib/api"; -import { langName } from "~/shared/lib/lang"; -import type { MediaItem, ReviewPlan } from "~/shared/lib/types"; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -interface MovieRow { - item: MediaItem; - plan: ReviewPlan | null; - removeCount: number; - keepCount: number; -} - -interface SeriesGroup { - series_key: string; - series_name: string; - original_language: string | null; - season_count: number; - episode_count: number; - noop_count: number; - needs_action_count: number; - approved_count: number; - skipped_count: number; - done_count: number; - error_count: number; - manual_count: number; -} - -interface ReviewListData { - movies: MovieRow[]; - series: SeriesGroup[]; - filter: string; - totalCounts: Record; -} - -// ─── Filter tabs ────────────────────────────────────────────────────────────── - -const FILTER_TABS = [ - { key: "all", label: "All" }, - { key: "needs_action", label: "Needs Action" }, - { key: "noop", label: "No Change" }, - { key: "manual", label: "Manual Review" }, - { key: "approved", label: "Approved" }, - { key: "skipped", label: "Skipped" }, - { key: "done", label: "Done" }, - { key: "error", label: "Error" }, -]; - -// ─── Status pills ───────────────────────────────────────────────────────────── - -function StatusPills({ g }: { g: SeriesGroup }) { - return ( - - {g.noop_count > 0 && ( - - {g.noop_count} ok - - )} - {g.needs_action_count > 0 && ( - - {g.needs_action_count} action - - )} - {g.approved_count > 0 && ( - - {g.approved_count} approved - - )} - {g.done_count > 0 && ( - - {g.done_count} done - - )} - {g.error_count > 0 && ( - - {g.error_count} err - - )} - {g.skipped_count > 0 && ( - - {g.skipped_count} skip - - )} - {g.manual_count > 0 && ( - - {g.manual_count} manual - - )} - - ); -} - -// ─── Th helper ─────────────────────────────────────────────────────────────── - -const Th = ({ children }: { children: React.ReactNode }) => ( - - {children} - -); - -const Td = ({ children, className }: { children?: React.ReactNode; className?: string }) => ( - {children} -); - -// ─── Series row (collapsible) ───────────────────────────────────────────────── - -function SeriesRow({ g }: { g: SeriesGroup }) { - const [open, setOpen] = useState(false); - const urlKey = encodeURIComponent(g.series_key); - - interface EpisodeItem { - item: MediaItem; - plan: ReviewPlan | null; - removeCount: number; - } - interface SeasonGroup { - season: number | null; - episodes: EpisodeItem[]; - noopCount: number; - actionCount: number; - approvedCount: number; - doneCount: number; - } - - const [seasons, setSeasons] = useState(null); - - const toggle = async () => { - if (!open && seasons === null) { - const data = await api.get<{ seasons: SeasonGroup[] }>(`/api/review/series/${urlKey}/episodes`); - setSeasons(data.seasons); - } - setOpen((v) => !v); - }; - - const approveAll = async (e: React.MouseEvent) => { - e.stopPropagation(); - await api.post(`/api/review/series/${urlKey}/approve-all`); - window.location.reload(); - }; - - const approveSeason = async (e: React.MouseEvent, season: number | null) => { - e.stopPropagation(); - await api.post(`/api/review/season/${urlKey}/${season ?? 0}/approve-all`); - window.location.reload(); - }; - - const statusKey = (plan: ReviewPlan | null) => (plan?.is_noop ? "noop" : (plan?.status ?? "pending")); - - return ( - - - - - ▶ - {" "} - {g.series_name} - - {langName(g.original_language)} - {g.season_count} - {g.episode_count} - - - - e.stopPropagation()}> - {g.needs_action_count > 0 && ( - - )} - - - {open && seasons && ( - - - - - {seasons.map((s) => ( - <> - - - - {s.episodes.map(({ item, plan, removeCount }) => ( - - - - - - - ))} - - ))} - -
- Season {s.season ?? "?"} - - {s.noopCount > 0 && ( - - {s.noopCount} ok - - )} - {s.actionCount > 0 && ( - - {s.actionCount} action - - )} - {s.approvedCount > 0 && ( - - {s.approvedCount} approved - - )} - {s.doneCount > 0 && ( - - {s.doneCount} done - - )} - - {s.actionCount > 0 && ( - - )} -
- - E{String(item.episode_number ?? 0).padStart(2, "0")} - {" "} - {item.name} - - {removeCount > 0 ? ( - −{removeCount} - ) : ( - - )} - - - {plan?.is_noop ? "ok" : (plan?.status ?? "pending")} - - - {plan?.status === "pending" && !plan.is_noop && } - {plan?.status === "pending" && } - {plan?.status === "skipped" && } - - Detail - -
- - - )} - - ); -} - -// ─── Action buttons ─────────────────────────────────────────────────────────── - -function ApproveBtn({ itemId, size }: { itemId: number; size?: "xs" | "sm" }) { - const onClick = async () => { - await api.post(`/api/review/${itemId}/approve`); - window.location.reload(); - }; - return ( - - ); -} - -function SkipBtn({ itemId, size }: { itemId: number; size?: "xs" | "sm" }) { - const onClick = async () => { - await api.post(`/api/review/${itemId}/skip`); - window.location.reload(); - }; - return ( - - ); -} - -function UnskipBtn({ itemId, size }: { itemId: number; size?: "xs" | "sm" }) { - const onClick = async () => { - await api.post(`/api/review/${itemId}/unskip`); - window.location.reload(); - }; - return ( - - ); -} - -// ─── Cache ──────────────────────────────────────────────────────────────────── - -const cache = new Map(); - -// ─── Main page ──────────────────────────────────────────────────────────────── - -export function AudioListPage() { - const { filter } = useSearch({ from: "/review/audio/" }); - const navigate = useNavigate(); - const [data, setData] = useState(cache.get(filter) ?? null); - const [loading, setLoading] = useState(!cache.has(filter)); - - useEffect(() => { - const cached = cache.get(filter); - if (cached) { - setData(cached); - setLoading(false); - } else { - setLoading(true); - } - api - .get(`/api/review?filter=${filter}`) - .then((d) => { - cache.set(filter, d); - setData(d); - setLoading(false); - }) - .catch(() => setLoading(false)); - }, [filter]); - - const approveAll = async () => { - await api.post("/api/review/approve-all"); - cache.clear(); - window.location.reload(); - }; - - if (loading) return
Loading…
; - if (!data) return
Failed to load.
; - - const { movies, series, totalCounts } = data; - const hasPending = (totalCounts.needs_action ?? 0) > 0; - const statusKey = (plan: ReviewPlan | null) => (plan?.is_noop ? "noop" : (plan?.status ?? "pending")); - - return ( -
-

Audio Review

- -
- {hasPending ? ( - <> - - {totalCounts.needs_action} item{totalCounts.needs_action !== 1 ? "s" : ""} need - {totalCounts.needs_action === 1 ? "s" : ""} review - - - - ) : ( - All items reviewed - )} -
- - navigate({ to: "/review/audio", search: { filter: key } as never })} - /> - - {movies.length === 0 && series.length === 0 &&

No items match this filter.

} - - {/* Movies */} - {movies.length > 0 && ( - <> -
- Movies {movies.length} -
-
- - - - - - - - - - - - {movies.map(({ item, plan, removeCount }) => ( - - - - - - - - ))} - -
NameLangRemoveStatusActions
- - {item.name} - - {item.year && ({item.year})} - - {item.needs_review && !item.original_language ? ( - manual - ) : ( - {langName(item.original_language)} - )} - - {removeCount > 0 ? −{removeCount} : } - - - {plan?.is_noop ? "ok" : (plan?.status ?? "pending")} - - - {plan?.status === "pending" && !plan.is_noop && } - {plan?.status === "pending" && } - {plan?.status === "skipped" && } - - Detail - -
-
- - )} - - {/* TV Series */} - {series.length > 0 && ( - <> -
0 ? "mt-5" : "mt-0"}`} - > - TV Series {series.length} -
-
- - - - - - - - - - - - {series.map((g) => ( - - ))} -
SeriesLangSEpStatusActions
-
- - )} -
- ); -} - -import type React from "react"; diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 82c9f40..ecd5943 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -69,7 +69,6 @@ function RootLayout() { Scan Pipeline - Audio Subtitles Jobs
diff --git a/src/routes/review/audio/index.tsx b/src/routes/review/audio/index.tsx deleted file mode 100644 index 1ebeda6..0000000 --- a/src/routes/review/audio/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { z } from "zod"; -import { AudioListPage } from "~/features/review/AudioListPage"; - -export const Route = createFileRoute("/review/audio/")({ - validateSearch: z.object({ - filter: z.enum(["all", "needs_action", "noop", "manual", "approved", "skipped", "done", "error"]).default("all"), - }), - component: AudioListPage, -}); diff --git a/src/routes/review/index.tsx b/src/routes/review/index.tsx index 8644144..b8a285d 100644 --- a/src/routes/review/index.tsx +++ b/src/routes/review/index.tsx @@ -2,6 +2,6 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/review/")({ beforeLoad: () => { - throw redirect({ to: "/review/audio" }); + throw redirect({ to: "/pipeline" }); }, });