remove standalone subtitle extract, unify done semantics, fix nav active matching
All checks were successful
Build and Push Docker Image / build (push) Successful in 49s

Subtitle extraction lives only in the pipeline now; a file is 'done' when it
matches the desired end state — no embedded subs AND audio matches the
language config. The separate Extract page was redundant.

- delete src/routes/review/subtitles/extract.tsx + SubtitleExtractPage
- delete /api/subtitles/extract-all + /:id/extract endpoints
- delete buildExtractOnlyCommand + unused buildExtractionOutputs from ffmpeg.ts
- detail page: drop Extract button + extractCommand textarea, replace with
  'will be extracted via pipeline' note when embedded subs present
- pipeline endpoint: doneCount = is_noop OR status='done' (a file in the
  desired state, however it got there); UI label 'N files in desired state'
- nav: drop the now-defunct 'Extract subs' link, default activeOptions.exact
  to false so detail subpages (e.g. /review/audio/123) highlight their
  parent ('Audio') in the menu — was the cause of the broken-feeling menu
This commit is contained in:
2026-04-13 09:41:46 +02:00
parent cc19d99292
commit 9ee0dd445f
9 changed files with 25 additions and 557 deletions

View File

@@ -281,7 +281,17 @@ app.get("/pipeline", (c) => {
`) `)
.all(); .all();
const noops = db.prepare("SELECT COUNT(*) as count FROM review_plans WHERE is_noop = 1").get() as { count: number }; // "Done" = files that are already in the desired end state. Two ways
// to get there: (a) the analyzer says nothing to do (is_noop=1), or
// (b) we ran a job that finished. Both count toward the same total.
const doneCount = (
db
.prepare(`
SELECT COUNT(*) as count FROM review_plans
WHERE is_noop = 1 OR status = 'done'
`)
.get() as { count: number }
).count;
// Batch transcode reasons for all review plans in one query (avoids N+1) // Batch transcode reasons for all review plans in one query (avoids N+1)
const planIds = (review as { id: number }[]).map((r) => r.id); const planIds = (review as { id: number }[]).map((r) => r.id);
@@ -305,7 +315,7 @@ app.get("/pipeline", (c) => {
item.transcode_reasons = reasonsByPlan.get(item.id) ?? []; item.transcode_reasons = reasonsByPlan.get(item.id) ?? [];
} }
return c.json({ review, queued, processing, done, noopCount: noops.count, jellyfinUrl }); return c.json({ review, queued, processing, done, doneCount, jellyfinUrl });
}); });
// ─── List ───────────────────────────────────────────────────────────────────── // ─── List ─────────────────────────────────────────────────────────────────────

View File

@@ -4,7 +4,6 @@ import { Hono } from "hono";
import { getAllConfig, getConfig, getDb } from "../db/index"; import { getAllConfig, getConfig, getDb } from "../db/index";
import { error as logError } from "../lib/log"; import { error as logError } from "../lib/log";
import { parseId } from "../lib/validate"; import { parseId } from "../lib/validate";
import { buildExtractOnlyCommand } from "../services/ffmpeg";
import { getItem, mapStream, normalizeLanguage, refreshItem } from "../services/jellyfin"; import { getItem, mapStream, normalizeLanguage, refreshItem } from "../services/jellyfin";
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision, SubtitleFile } from "../types"; import type { MediaItem, MediaStream, ReviewPlan, StreamDecision, SubtitleFile } from "../types";
@@ -59,11 +58,6 @@ function loadDetail(db: ReturnType<typeof getDb>, itemId: number) {
) )
.all(plan.id) as StreamDecision[]) .all(plan.id) as StreamDecision[])
: []; : [];
const allStreams = db
.prepare("SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index")
.all(itemId) as MediaStream[];
const extractCommand = buildExtractOnlyCommand(item, allStreams);
return { return {
item, item,
subtitleStreams, subtitleStreams,
@@ -71,7 +65,6 @@ function loadDetail(db: ReturnType<typeof getDb>, itemId: number) {
plan: plan ?? null, plan: plan ?? null,
decisions, decisions,
subs_extracted: plan?.subs_extracted ?? 0, subs_extracted: plan?.subs_extracted ?? 0,
extractCommand,
}; };
} }
@@ -362,61 +355,6 @@ app.patch("/:id/stream/:streamId/title", async (c) => {
return c.json(detail); return c.json(detail);
}); });
// ─── Extract all ──────────────────────────────────────────────────────────────
app.post("/extract-all", (c) => {
const db = getDb();
// Find items with subtitle streams that haven't been extracted yet
const items = db
.prepare(`
SELECT mi.* FROM media_items mi
WHERE EXISTS (SELECT 1 FROM media_streams ms WHERE ms.item_id = mi.id AND ms.type = 'Subtitle')
AND NOT EXISTS (SELECT 1 FROM review_plans rp WHERE rp.item_id = mi.id AND rp.subs_extracted = 1)
AND NOT EXISTS (SELECT 1 FROM jobs j WHERE j.item_id = mi.id AND j.status IN ('pending', 'running'))
`)
.all() as MediaItem[];
let queued = 0;
for (const item of items) {
const streams = db
.prepare("SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index")
.all(item.id) as MediaStream[];
const command = buildExtractOnlyCommand(item, streams);
if (!command) continue;
db
.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'subtitle', 'pending')")
.run(item.id, command);
queued++;
}
return c.json({ ok: true, queued });
});
// ─── Extract ─────────────────────────────────────────────────────────────────
app.post("/:id/extract", (c) => {
const db = getDb();
const id = parseId(c.req.param("id"));
if (id == null) return c.json({ error: "invalid id" }, 400);
const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(id) as MediaItem | undefined;
if (!item) return c.notFound();
const plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(id) as ReviewPlan | undefined;
if (plan?.subs_extracted) return c.json({ ok: false, error: "Subtitles already extracted" }, 409);
const streams = db
.prepare("SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index")
.all(id) as MediaStream[];
const command = buildExtractOnlyCommand(item, streams);
if (!command) return c.json({ ok: false, error: "No subtitles to extract" }, 400);
db
.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'subtitle', 'pending')")
.run(id, command);
return c.json({ ok: true });
});
// ─── Delete file ───────────────────────────────────────────────────────────── // ─── Delete file ─────────────────────────────────────────────────────────────
/** /**

View File

@@ -137,15 +137,6 @@ function computeExtractionEntries(allStreams: MediaStream[], basePath: string):
return entries; return entries;
} }
function buildExtractionOutputs(allStreams: MediaStream[], basePath: string): string[] {
const entries = computeExtractionEntries(allStreams, basePath);
const args: string[] = [];
for (const e of entries) {
args.push(`-map 0:s:${e.typeIdx}`, `-c:s ${e.codecArg}`, shellQuote(e.outPath));
}
return args;
}
/** /**
* Predict the sidecar files that subtitle extraction will create. * Predict the sidecar files that subtitle extraction will create.
* Used to populate the subtitle_files table after a successful job. * Used to populate the subtitle_files table after a successful job.
@@ -378,48 +369,6 @@ export function buildMkvConvertCommand(item: MediaItem, streams: MediaStream[],
].join(" "); ].join(" ");
} }
/**
* Build a command that extracts subtitles to sidecar files AND
* remuxes the container without subtitle streams (single ffmpeg pass).
*
* ffmpeg supports multiple outputs: first we extract each subtitle
* track to its own sidecar file, then the final output copies all
* video + audio streams into a temp file without subtitles.
*/
export function buildExtractOnlyCommand(item: MediaItem, streams: MediaStream[]): string | null {
const basePath = item.file_path.replace(/\.[^.]+$/, "");
const extractionOutputs = buildExtractionOutputs(streams, basePath);
if (extractionOutputs.length === 0) return null;
const inputPath = item.file_path;
const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? "mkv";
const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
// Only map audio if the file actually has audio streams
const hasAudio = streams.some((s) => s.type === "Audio");
const remuxMaps = hasAudio ? ["-map 0:v", "-map 0:a"] : ["-map 0:v"];
// Single ffmpeg pass: extract sidecar files + remux without subtitles
const parts: string[] = [
"ffmpeg",
"-y",
"-i",
shellQuote(inputPath),
// Subtitle extraction outputs (each to its own file)
...extractionOutputs,
// Final output: copy all video + audio, no subtitles
...remuxMaps,
"-c copy",
shellQuote(tmpPath),
"&&",
"mv",
shellQuote(tmpPath),
shellQuote(inputPath),
];
return parts.join(" ");
}
/** /**
* Build a single FFmpeg command that: * Build a single FFmpeg command that:
* 1. Extracts subtitles to sidecar files * 1. Extracts subtitles to sidecar files

View File

@@ -11,7 +11,7 @@ interface PipelineData {
queued: any[]; queued: any[];
processing: any[]; processing: any[];
done: any[]; done: any[];
noopCount: number; doneCount: number;
jellyfinUrl: string; jellyfinUrl: string;
} }
@@ -75,7 +75,7 @@ export function PipelinePage() {
<div className="flex items-center justify-between px-6 py-3 border-b shrink-0"> <div className="flex items-center justify-between px-6 py-3 border-b shrink-0">
<h1 className="text-lg font-semibold">Pipeline</h1> <h1 className="text-lg font-semibold">Pipeline</h1>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-sm text-gray-500">{data.noopCount} files already processed</span> <span className="text-sm text-gray-500">{data.doneCount} files in desired state</span>
{scheduler && <ScheduleControls scheduler={scheduler} onUpdate={load} />} {scheduler && <ScheduleControls scheduler={scheduler} onUpdate={load} />}
</div> </div>
</div> </div>

View File

@@ -17,7 +17,6 @@ interface DetailData {
plan: ReviewPlan | null; plan: ReviewPlan | null;
decisions: StreamDecision[]; decisions: StreamDecision[];
subs_extracted: number; subs_extracted: number;
extractCommand: string | null;
} }
// ─── Utilities ──────────────────────────────────────────────────────────────── // ─── Utilities ────────────────────────────────────────────────────────────────
@@ -218,7 +217,6 @@ export function SubtitleDetailPage() {
const { id } = useParams({ from: "/review/subtitles/$id" }); const { id } = useParams({ from: "/review/subtitles/$id" });
const [data, setData] = useState<DetailData | null>(null); const [data, setData] = useState<DetailData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [extracting, setExtracting] = useState(false);
const [rescanning, setRescanning] = useState(false); const [rescanning, setRescanning] = useState(false);
const load = () => const load = () =>
@@ -243,16 +241,6 @@ export function SubtitleDetailPage() {
setData(d); setData(d);
}; };
const extract = async () => {
setExtracting(true);
try {
await api.post(`/api/subtitles/${id}/extract`);
load();
} finally {
setExtracting(false);
}
};
const deleteFile = async (fileId: number) => { const deleteFile = async (fileId: number) => {
const resp = await api.delete<{ ok: boolean; files: SubtitleFile[] }>(`/api/subtitles/${id}/files/${fileId}`); const resp = await api.delete<{ ok: boolean; files: SubtitleFile[] }>(`/api/subtitles/${id}/files/${fileId}`);
if (data) setData({ ...data, files: resp.files }); if (data) setData({ ...data, files: resp.files });
@@ -271,7 +259,7 @@ export function SubtitleDetailPage() {
if (loading) return <div className="text-gray-400 py-8 text-center">Loading</div>; if (loading) return <div className="text-gray-400 py-8 text-center">Loading</div>;
if (!data) return <Alert variant="error">Item not found.</Alert>; if (!data) return <Alert variant="error">Item not found.</Alert>;
const { item, subtitleStreams, files, decisions, subs_extracted, extractCommand } = data; const { item, subtitleStreams, files, decisions, subs_extracted } = data;
const hasContainerSubs = subtitleStreams.length > 0; const hasContainerSubs = subtitleStreams.length > 0;
const editable = !subs_extracted && hasContainerSubs; const editable = !subs_extracted && hasContainerSubs;
@@ -332,26 +320,10 @@ export function SubtitleDetailPage() {
</div> </div>
)} )}
{/* FFmpeg commands */}
{extractCommand && (
<div className="mt-6">
<div className="text-gray-400 text-[0.75rem] uppercase tracking-[0.05em] mb-1">Extraction command</div>
<textarea
readOnly
rows={3}
value={extractCommand}
className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#9cdcfe] p-3 rounded w-full resize-y border-0 min-h-10"
/>
</div>
)}
{/* Actions */}
{hasContainerSubs && !subs_extracted && ( {hasContainerSubs && !subs_extracted && (
<div className="flex gap-2 mt-6"> <Alert variant="warning" className="mt-4">
<Button onClick={extract} disabled={extracting}> Embedded subtitles present they'll be extracted to sidecar files when this item is approved in the pipeline.
{extracting ? "Queuing…" : "✓ Extract All"} </Alert>
</Button>
</div>
)} )}
{subs_extracted ? ( {subs_extracted ? (

View File

@@ -1,389 +0,0 @@
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import type React from "react";
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";
// ─── Types ────────────────────────────────────────────────────────────────────
interface SubListItem {
id: number;
name: string;
type: string;
series_name: string | null;
season_number: number | null;
episode_number: number | null;
year: number | null;
original_language: string | null;
subs_extracted: number | null;
sub_count: number;
file_count: number;
}
interface SubSeriesGroup {
series_key: string;
series_name: string;
original_language: string | null;
season_count: number;
episode_count: number;
not_extracted_count: number;
extracted_count: number;
no_subs_count: number;
}
interface SubListData {
movies: SubListItem[];
series: SubSeriesGroup[];
filter: string;
totalCounts: Record<string, number>;
}
interface SeasonGroup {
season: number | null;
episodes: SubListItem[];
extractedCount: number;
notExtractedCount: number;
noSubsCount: number;
}
const FILTER_TABS = [
{ key: "all", label: "All" },
{ key: "not_extracted", label: "Not Extracted" },
{ key: "extracted", label: "Extracted" },
{ key: "no_subs", label: "No Subtitles" },
];
// ─── Table helpers ────────────────────────────────────────────────────────────
const Th = ({ children }: { children?: React.ReactNode }) => (
<th className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">
{children}
</th>
);
const Td = ({ children, className }: { children?: React.ReactNode; className?: string }) => (
<td className={`py-1.5 px-2 border-b border-gray-100 align-middle ${className ?? ""}`}>{children}</td>
);
function subStatus(item: SubListItem): "extracted" | "not_extracted" | "no_subs" {
if (item.sub_count === 0) return "no_subs";
return item.subs_extracted ? "extracted" : "not_extracted";
}
function StatusBadge({ item }: { item: SubListItem }) {
const s = subStatus(item);
if (s === "extracted") return <Badge variant="keep">extracted</Badge>;
if (s === "not_extracted") return <Badge variant="pending">pending</Badge>;
return <Badge variant="noop">no subs</Badge>;
}
function StatusPills({ g }: { g: SubSeriesGroup }) {
return (
<span className="inline-flex flex-wrap gap-1 items-center">
{g.extracted_count > 0 && (
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-green-100 text-green-800">
{g.extracted_count} extracted
</span>
)}
{g.not_extracted_count > 0 && (
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-800">
{g.not_extracted_count} pending
</span>
)}
{g.no_subs_count > 0 && (
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">
{g.no_subs_count} no subs
</span>
)}
</span>
);
}
// ─── Action box ──────────────────────────────────────────────────────────────
function ActionBox({ count, onExtract }: { count: number | null; onExtract: () => void }) {
const [extracting, setExtracting] = useState(false);
const [result, setResult] = useState("");
const handleExtract = async () => {
setExtracting(true);
setResult("");
try {
const r = await api.post<{ ok: boolean; queued: number }>("/api/subtitles/extract-all");
setResult(`Queued ${r.queued} extraction job${r.queued !== 1 ? "s" : ""}.`);
onExtract();
} catch (e) {
setResult(`Error: ${e}`);
}
setExtracting(false);
};
const allDone = count !== null && count === 0;
return (
<div className="border border-gray-200 rounded-lg px-4 py-3 mb-6 flex items-center gap-3 flex-wrap">
{count === null && <span className="text-sm text-gray-400">Loading...</span>}
{allDone && <span className="text-sm font-medium">All subtitles extracted</span>}
{count !== null && count > 0 && (
<>
<span className="text-sm font-medium">
{count} item{count !== 1 ? "s have" : " has"} embedded subtitles to extract
</span>
<Button size="sm" onClick={handleExtract} disabled={extracting}>
{extracting ? "Queuing..." : "Extract All"}
</Button>
</>
)}
{result && <span className="text-sm text-gray-600">{result}</span>}
</div>
);
}
// ─── Series row (collapsible) ─────────────────────────────────────────────────
function SeriesRow({ g }: { g: SubSeriesGroup }) {
const [open, setOpen] = useState(false);
const urlKey = encodeURIComponent(g.series_key);
const [seasons, setSeasons] = useState<SeasonGroup[] | null>(null);
const toggle = async () => {
if (!open && seasons === null) {
const data = await api.get<{ seasons: SeasonGroup[] }>(`/api/subtitles/series/${urlKey}/episodes`);
setSeasons(data.seasons);
}
setOpen((v) => !v);
};
return (
<tbody>
<tr className="cursor-pointer hover:bg-gray-50" onClick={toggle}>
<td className="py-1.5 px-2 border-b border-gray-100 font-medium">
<span
className={`inline-flex items-center justify-center w-4 h-4 rounded text-[0.65rem] text-gray-500 transition-transform ${open ? "rotate-90" : ""}`}
>
</span>{" "}
<strong>{g.series_name}</strong>
</td>
<Td>{langName(g.original_language)}</Td>
<td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.season_count}</td>
<td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.episode_count}</td>
<Td>
<StatusPills g={g} />
</Td>
</tr>
{open && seasons && (
<tr>
<td colSpan={5} className="p-0 border-b border-gray-100">
<table className="w-full border-collapse">
<tbody>
{seasons.map((s) => (
<>
<tr key={`season-${s.season}`} className="bg-gray-50">
<td
colSpan={5}
className="text-[0.7rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-0.5 px-8 border-b border-gray-100"
>
Season {s.season ?? "?"}
<span className="ml-3 inline-flex gap-1">
{s.extractedCount > 0 && (
<span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-800 text-[0.7rem]">
{s.extractedCount} extracted
</span>
)}
{s.notExtractedCount > 0 && (
<span className="px-1.5 py-0.5 rounded-full bg-red-100 text-red-800 text-[0.7rem]">
{s.notExtractedCount} pending
</span>
)}
{s.noSubsCount > 0 && (
<span className="px-1.5 py-0.5 rounded-full bg-gray-200 text-gray-600 text-[0.7rem]">
{s.noSubsCount} no subs
</span>
)}
</span>
</td>
</tr>
{s.episodes.map((item) => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem] pl-10">
<span className="text-gray-400 font-mono text-xs">
E{String(item.episode_number ?? 0).padStart(2, "0")}
</span>{" "}
<Link
to="/review/subtitles/$id"
params={{ id: String(item.id) }}
className="no-underline text-blue-600 hover:text-blue-800"
>
<span className="truncate inline-block max-w-xs align-bottom">{item.name}</span>
</Link>
</td>
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem] font-mono text-xs">{item.sub_count}</td>
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem] font-mono text-xs">{item.file_count}</td>
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem]">
<StatusBadge item={item} />
</td>
<td className="py-1 px-2 border-b border-gray-100 whitespace-nowrap">
<Link
to="/review/subtitles/$id"
params={{ id: String(item.id) }}
className="inline-flex items-center justify-center gap-1 rounded px-2 py-0.5 text-xs font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline"
>
Detail
</Link>
</td>
</tr>
))}
</>
))}
</tbody>
</table>
</td>
</tr>
)}
</tbody>
);
}
// ─── Cache ────────────────────────────────────────────────────────────────────
const cache = new Map<string, SubListData>();
// ─── Main page ────────────────────────────────────────────────────────────────
export function SubtitleExtractPage() {
const { filter } = useSearch({ from: "/review/subtitles/extract" });
const navigate = useNavigate();
const [data, setData] = useState<SubListData | null>(cache.get(filter) ?? null);
const [loading, setLoading] = useState(!cache.has(filter));
const [embeddedCount, setEmbeddedCount] = useState<number | null>(null);
const load = () => {
if (!cache.has(filter)) setLoading(true);
api
.get<SubListData>(`/api/subtitles?filter=${filter}`)
.then((d) => {
cache.set(filter, d);
setData(d);
})
.catch(() => {})
.finally(() => setLoading(false));
};
const loadEmbedded = () => {
api
.get<{ embeddedCount: number }>("/api/subtitles/summary")
.then((d) => setEmbeddedCount(d.embeddedCount))
.catch(() => {});
};
useEffect(() => {
load();
loadEmbedded();
}, [load, loadEmbedded]);
const refresh = () => {
cache.clear();
load();
loadEmbedded();
};
return (
<div>
<h1 className="text-xl font-bold mb-4">Subtitle Extraction</h1>
<ActionBox count={embeddedCount} onExtract={refresh} />
<FilterTabs
tabs={FILTER_TABS}
filter={filter}
totalCounts={data?.totalCounts ?? {}}
onFilterChange={(key) => navigate({ to: "/review/subtitles/extract", search: { filter: key } as never })}
/>
{loading && !data && <div className="text-gray-400 py-4 text-center text-sm">Loading...</div>}
{data && !loading && (
<>
{data.movies.length === 0 && data.series.length === 0 && (
<p className="text-gray-500 text-sm">No items match this filter.</p>
)}
{data.movies.length > 0 && (
<>
<div className="flex items-center gap-2 mt-3 mb-1.5 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-gray-500">
Movies <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{data.movies.length}</span>
</div>
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
<table className="w-full border-collapse text-[0.82rem]">
<thead>
<tr>
<Th>Name</Th>
<Th>Lang</Th>
<Th>Subs</Th>
<Th>Files</Th>
<Th>Status</Th>
</tr>
</thead>
<tbody>
{data.movies.map((item) => (
<tr key={item.id} className="hover:bg-gray-50">
<Td>
<Link
to="/review/subtitles/$id"
params={{ id: String(item.id) }}
className="no-underline text-blue-600 hover:text-blue-800"
>
<span className="truncate inline-block max-w-[200px] sm:max-w-[360px]" title={item.name}>
{item.name}
</span>
</Link>
{item.year && <span className="text-gray-400 text-[0.72rem]"> ({item.year})</span>}
</Td>
<Td>{langName(item.original_language)}</Td>
<Td className="font-mono text-xs">{item.sub_count}</Td>
<Td className="font-mono text-xs">{item.file_count}</Td>
<Td>
<StatusBadge item={item} />
</Td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{data.series.length > 0 && (
<>
<div
className={`flex items-center gap-2 mb-1.5 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-gray-500 ${data.movies.length > 0 ? "mt-5" : "mt-0"}`}
>
TV Series <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{data.series.length}</span>
</div>
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
<table className="w-full border-collapse text-[0.82rem]">
<thead>
<tr>
<Th>Series</Th>
<Th>Lang</Th>
<Th>S</Th>
<Th>Ep</Th>
<Th>Status</Th>
</tr>
</thead>
{data.series.map((g) => (
<SeriesRow key={g.series_key} g={g} />
))}
</table>
</div>
</>
)}
</>
)}
</div>
);
}

View File

@@ -9,7 +9,7 @@ export const Route = createRootRoute({
component: RootLayout, component: RootLayout,
}); });
function NavLink({ to, children }: { to: string; children: React.ReactNode }) { function NavLink({ to, children, exact = false }: { to: string; children: React.ReactNode; exact?: boolean }) {
return ( return (
<Link <Link
to={to} to={to}
@@ -17,7 +17,7 @@ function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
"px-2.5 py-1 rounded text-[0.85rem] no-underline transition-colors text-gray-500 hover:bg-gray-100 hover:text-gray-900", "px-2.5 py-1 rounded text-[0.85rem] no-underline transition-colors text-gray-500 hover:bg-gray-100 hover:text-gray-900",
)} )}
activeProps={{ className: "bg-gray-100 text-gray-900 font-medium" }} activeProps={{ className: "bg-gray-100 text-gray-900 font-medium" }}
activeOptions={{ exact: true }} activeOptions={{ exact }}
> >
{children} {children}
</Link> </Link>
@@ -64,12 +64,13 @@ function RootLayout() {
</Link> </Link>
<VersionBadge /> <VersionBadge />
<div className="flex flex-wrap items-center gap-0.5"> <div className="flex flex-wrap items-center gap-0.5">
<NavLink to="/">Dashboard</NavLink> <NavLink to="/" exact>
Dashboard
</NavLink>
<NavLink to="/scan">Scan</NavLink> <NavLink to="/scan">Scan</NavLink>
<NavLink to="/pipeline">Pipeline</NavLink> <NavLink to="/pipeline">Pipeline</NavLink>
<NavLink to="/review/audio">Audio</NavLink> <NavLink to="/review/audio">Audio</NavLink>
<NavLink to="/review/subtitles/extract">Extract subs</NavLink> <NavLink to="/review/subtitles">Subtitles</NavLink>
<NavLink to="/review/subtitles">Subtitle mgr</NavLink>
<NavLink to="/execute">Jobs</NavLink> <NavLink to="/execute">Jobs</NavLink>
</div> </div>
<div className="flex-1" /> <div className="flex-1" />

View File

@@ -1,10 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import { SubtitleExtractPage } from "~/features/subtitles/SubtitleExtractPage";
export const Route = createFileRoute("/review/subtitles/extract")({
validateSearch: z.object({
filter: z.enum(["all", "not_extracted", "extracted", "no_subs"]).default("not_extracted"),
}),
component: SubtitleExtractPage,
});

View File

@@ -1,7 +1,4 @@
{ {
"files": [], "files": [],
"references": [ "references": [{ "path": "./tsconfig.client.json" }, { "path": "./tsconfig.server.json" }]
{ "path": "./tsconfig.client.json" },
{ "path": "./tsconfig.server.json" }
]
} }