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();
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 ─────────────────────────────────────────────────────────────────────

View File

@@ -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<typeof getDb>, 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<typeof getDb>, 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 ─────────────────────────────────────────────────────────────
/**

View File

@@ -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