diff --git a/server/api/review.ts b/server/api/review.ts index 09f9e05..1f5d36e 100644 --- a/server/api/review.ts +++ b/server/api/review.ts @@ -281,7 +281,17 @@ app.get("/pipeline", (c) => { `) .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) 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) ?? []; } - return c.json({ review, queued, processing, done, noopCount: noops.count, jellyfinUrl }); + return c.json({ review, queued, processing, done, doneCount, jellyfinUrl }); }); // ─── List ───────────────────────────────────────────────────────────────────── diff --git a/server/api/subtitles.ts b/server/api/subtitles.ts index 3b31011..beb15d7 100644 --- a/server/api/subtitles.ts +++ b/server/api/subtitles.ts @@ -4,7 +4,6 @@ import { Hono } from "hono"; import { getAllConfig, getConfig, getDb } from "../db/index"; import { error as logError } from "../lib/log"; import { parseId } from "../lib/validate"; -import { buildExtractOnlyCommand } from "../services/ffmpeg"; import { getItem, mapStream, normalizeLanguage, refreshItem } from "../services/jellyfin"; import type { MediaItem, MediaStream, ReviewPlan, StreamDecision, SubtitleFile } from "../types"; @@ -59,11 +58,6 @@ function loadDetail(db: ReturnType, itemId: number) { ) .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 { item, subtitleStreams, @@ -71,7 +65,6 @@ function loadDetail(db: ReturnType, itemId: number) { plan: plan ?? null, decisions, subs_extracted: plan?.subs_extracted ?? 0, - extractCommand, }; } @@ -362,61 +355,6 @@ app.patch("/:id/stream/:streamId/title", async (c) => { 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 ───────────────────────────────────────────────────────────── /** diff --git a/server/services/ffmpeg.ts b/server/services/ffmpeg.ts index b5c9bd9..ac302b3 100644 --- a/server/services/ffmpeg.ts +++ b/server/services/ffmpeg.ts @@ -137,15 +137,6 @@ function computeExtractionEntries(allStreams: MediaStream[], basePath: string): 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. * Used to populate the subtitle_files table after a successful job. @@ -378,48 +369,6 @@ export function buildMkvConvertCommand(item: MediaItem, streams: MediaStream[], ].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: * 1. Extracts subtitles to sidecar files diff --git a/src/features/pipeline/PipelinePage.tsx b/src/features/pipeline/PipelinePage.tsx index 68ad39f..bffe943 100644 --- a/src/features/pipeline/PipelinePage.tsx +++ b/src/features/pipeline/PipelinePage.tsx @@ -11,7 +11,7 @@ interface PipelineData { queued: any[]; processing: any[]; done: any[]; - noopCount: number; + doneCount: number; jellyfinUrl: string; } @@ -75,7 +75,7 @@ export function PipelinePage() {

Pipeline

- {data.noopCount} files already processed + {data.doneCount} files in desired state {scheduler && }
diff --git a/src/features/subtitles/SubtitleDetailPage.tsx b/src/features/subtitles/SubtitleDetailPage.tsx index b9b1fc1..c5fd20d 100644 --- a/src/features/subtitles/SubtitleDetailPage.tsx +++ b/src/features/subtitles/SubtitleDetailPage.tsx @@ -17,7 +17,6 @@ interface DetailData { plan: ReviewPlan | null; decisions: StreamDecision[]; subs_extracted: number; - extractCommand: string | null; } // ─── Utilities ──────────────────────────────────────────────────────────────── @@ -218,7 +217,6 @@ export function SubtitleDetailPage() { const { id } = useParams({ from: "/review/subtitles/$id" }); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); - const [extracting, setExtracting] = useState(false); const [rescanning, setRescanning] = useState(false); const load = () => @@ -243,16 +241,6 @@ export function SubtitleDetailPage() { 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 resp = await api.delete<{ ok: boolean; files: SubtitleFile[] }>(`/api/subtitles/${id}/files/${fileId}`); if (data) setData({ ...data, files: resp.files }); @@ -271,7 +259,7 @@ export function SubtitleDetailPage() { if (loading) return
Loading…
; if (!data) return Item not found.; - const { item, subtitleStreams, files, decisions, subs_extracted, extractCommand } = data; + const { item, subtitleStreams, files, decisions, subs_extracted } = data; const hasContainerSubs = subtitleStreams.length > 0; const editable = !subs_extracted && hasContainerSubs; @@ -332,26 +320,10 @@ export function SubtitleDetailPage() { )} - {/* FFmpeg commands */} - {extractCommand && ( -
-
Extraction command
-