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
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:
@@ -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 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
{
|
{
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [{ "path": "./tsconfig.client.json" }, { "path": "./tsconfig.server.json" }]
|
||||||
{ "path": "./tsconfig.client.json" },
|
|
||||||
{ "path": "./tsconfig.server.json" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user