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();
|
||||
|
||||
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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user