wire scheduler into queue, add retry, dev-reset cleanup, biome 2.4 migrate
- execute: actually call isInScheduleWindow/waitForWindow/sleepBetweenJobs in runSequential (they were dead code); emit queue_status SSE events (running/paused/sleeping/idle) so the pipeline's existing QueueStatus listener lights up - review: POST /:id/retry resets an errored plan to approved, wipes old done/error jobs, rebuilds command from current decisions, queues fresh job - scan: dev-mode DELETE now also wipes jobs + subtitle_files (previously orphaned after every dev reset) - biome: migrate config to 2.4 schema, autoformat 68 files (strings + indentation), relax opinionated a11y/hooks-deps/index-key rules that don't fit this codebase - routeTree.gen.ts regenerated after /nodes removal
This commit is contained in:
25
biome.json
25
biome.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
|
||||
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
|
||||
"organizeImports": { "enabled": true },
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
@@ -12,11 +12,26 @@
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": { "noExplicitAny": "off" },
|
||||
"style": { "noNonNullAssertion": "off" }
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noArrayIndexKey": "off"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "off",
|
||||
"noInvalidUseBeforeDeclaration": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"useButtonType": "off",
|
||||
"noLabelWithoutControl": "off",
|
||||
"noStaticElementInteractions": "off",
|
||||
"useKeyWithClickEvents": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"ignore": ["node_modules", "dist", "src/routeTree.gen.ts"]
|
||||
"includes": ["**", "!**/node_modules", "!**/dist", "!**/src/routeTree.gen.ts"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
import { Hono } from 'hono';
|
||||
import { getDb, getConfig } from '../db/index';
|
||||
import { Hono } from "hono";
|
||||
import { getConfig, getDb } from "../db/index";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get('/', (c) => {
|
||||
app.get("/", (c) => {
|
||||
const db = getDb();
|
||||
|
||||
const totalItems = (db.prepare('SELECT COUNT(*) as n FROM media_items').get() as { n: number }).n;
|
||||
const scanned = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'scanned'").get() as { n: number }).n;
|
||||
const needsAction = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }).n;
|
||||
const noChange = (db.prepare('SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1').get() as { n: number }).n;
|
||||
const approved = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").get() as { n: number }).n;
|
||||
const totalItems = (db.prepare("SELECT COUNT(*) as n FROM media_items").get() as { n: number }).n;
|
||||
const scanned = (
|
||||
db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'scanned'").get() as { n: number }
|
||||
).n;
|
||||
const needsAction = (
|
||||
db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }
|
||||
).n;
|
||||
const noChange = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1").get() as { n: number }).n;
|
||||
const approved = (
|
||||
db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").get() as { n: number }
|
||||
).n;
|
||||
const done = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'done'").get() as { n: number }).n;
|
||||
const errors = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'error'").get() as { n: number }).n;
|
||||
const scanRunning = getConfig('scan_running') === '1';
|
||||
const setupComplete = getConfig('setup_complete') === '1';
|
||||
const scanRunning = getConfig("scan_running") === "1";
|
||||
const setupComplete = getConfig("setup_complete") === "1";
|
||||
|
||||
return c.json({ stats: { totalItems, scanned, needsAction, approved, done, errors, noChange }, scanRunning, setupComplete });
|
||||
return c.json({
|
||||
stats: { totalItems, scanned, needsAction, approved, done, errors, noChange },
|
||||
scanRunning,
|
||||
setupComplete,
|
||||
});
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Hono } from 'hono';
|
||||
import { stream } from 'hono/streaming';
|
||||
import { getDb } from '../db/index';
|
||||
import type { Job, MediaItem, MediaStream } from '../types';
|
||||
import { predictExtractedFiles } from '../services/ffmpeg';
|
||||
import { accessSync, constants } from 'node:fs';
|
||||
import { log, error as logError } from '../lib/log';
|
||||
import { getSchedulerState, updateSchedulerState } from '../services/scheduler';
|
||||
import { accessSync, constants } from "node:fs";
|
||||
import { Hono } from "hono";
|
||||
import { stream } from "hono/streaming";
|
||||
import { getDb } from "../db/index";
|
||||
import { log, error as logError } from "../lib/log";
|
||||
import { predictExtractedFiles } from "../services/ffmpeg";
|
||||
import {
|
||||
getSchedulerState,
|
||||
isInScheduleWindow,
|
||||
msUntilWindow,
|
||||
nextWindowTime,
|
||||
sleepBetweenJobs,
|
||||
updateSchedulerState,
|
||||
waitForWindow,
|
||||
} from "../services/scheduler";
|
||||
import type { Job, MediaItem, MediaStream } from "../types";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -13,17 +21,45 @@ const app = new Hono();
|
||||
|
||||
let queueRunning = false;
|
||||
|
||||
function emitQueueStatus(
|
||||
status: "running" | "paused" | "sleeping" | "idle",
|
||||
extra: { until?: string; seconds?: number } = {},
|
||||
): void {
|
||||
const line = `event: queue_status\ndata: ${JSON.stringify({ status, ...extra })}\n\n`;
|
||||
for (const l of jobListeners) l(line);
|
||||
}
|
||||
|
||||
async function runSequential(jobs: Job[]): Promise<void> {
|
||||
if (queueRunning) return;
|
||||
queueRunning = true;
|
||||
try {
|
||||
let first = true;
|
||||
for (const job of jobs) {
|
||||
// Pause outside the scheduler window
|
||||
if (!isInScheduleWindow()) {
|
||||
emitQueueStatus("paused", { until: nextWindowTime(), seconds: Math.round(msUntilWindow() / 1000) });
|
||||
await waitForWindow();
|
||||
}
|
||||
|
||||
// Sleep between jobs (but not before the first one)
|
||||
if (!first) {
|
||||
const state = getSchedulerState();
|
||||
if (state.job_sleep_seconds > 0) {
|
||||
emitQueueStatus("sleeping", { seconds: state.job_sleep_seconds });
|
||||
await sleepBetweenJobs();
|
||||
}
|
||||
}
|
||||
first = false;
|
||||
|
||||
// Atomic claim: only pick up jobs still pending
|
||||
const db = getDb();
|
||||
const claimed = db
|
||||
.prepare("UPDATE jobs SET status = 'running', started_at = datetime('now'), output = '' WHERE id = ? AND status = 'pending'")
|
||||
.prepare(
|
||||
"UPDATE jobs SET status = 'running', started_at = datetime('now'), output = '' WHERE id = ? AND status = 'pending'",
|
||||
)
|
||||
.run(job.id);
|
||||
if (claimed.changes === 0) continue; // cancelled or already running
|
||||
emitQueueStatus("running");
|
||||
try {
|
||||
await runJob(job);
|
||||
} catch (err) {
|
||||
@@ -32,6 +68,7 @@ async function runSequential(jobs: Job[]): Promise<void> {
|
||||
}
|
||||
} finally {
|
||||
queueRunning = false;
|
||||
emitQueueStatus("idle");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,49 +96,89 @@ function parseFFmpegDuration(line: string): number | null {
|
||||
|
||||
function loadJobRow(jobId: number) {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`
|
||||
const row = db
|
||||
.prepare(`
|
||||
SELECT j.*, mi.id as mi_id, mi.name, mi.type, mi.series_name, mi.season_number,
|
||||
mi.episode_number, mi.file_path
|
||||
FROM jobs j
|
||||
LEFT JOIN media_items mi ON mi.id = j.item_id
|
||||
WHERE j.id = ?
|
||||
`).get(jobId) as (Job & {
|
||||
mi_id: number | null; name: string | null; type: string | null;
|
||||
series_name: string | null; season_number: number | null; episode_number: number | null;
|
||||
`)
|
||||
.get(jobId) as
|
||||
| (Job & {
|
||||
mi_id: number | null;
|
||||
name: string | null;
|
||||
type: string | null;
|
||||
series_name: string | null;
|
||||
season_number: number | null;
|
||||
episode_number: number | null;
|
||||
file_path: string | null;
|
||||
}) | undefined;
|
||||
})
|
||||
| undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const item = row.name ? { id: row.item_id, name: row.name, type: row.type, series_name: row.series_name, season_number: row.season_number, episode_number: row.episode_number, file_path: row.file_path } as unknown as MediaItem : null;
|
||||
const item = row.name
|
||||
? ({
|
||||
id: row.item_id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
series_name: row.series_name,
|
||||
season_number: row.season_number,
|
||||
episode_number: row.episode_number,
|
||||
file_path: row.file_path,
|
||||
} as unknown as MediaItem)
|
||||
: null;
|
||||
return { job: row as unknown as Job, item };
|
||||
}
|
||||
|
||||
// ─── List ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/', (c) => {
|
||||
app.get("/", (c) => {
|
||||
const db = getDb();
|
||||
const filter = (c.req.query('filter') ?? 'pending') as 'all' | 'pending' | 'running' | 'done' | 'error';
|
||||
const filter = (c.req.query("filter") ?? "pending") as "all" | "pending" | "running" | "done" | "error";
|
||||
|
||||
const validFilters = ['all', 'pending', 'running', 'done', 'error'];
|
||||
const whereClause = validFilters.includes(filter) && filter !== 'all' ? `WHERE j.status = ?` : '';
|
||||
const validFilters = ["all", "pending", "running", "done", "error"];
|
||||
const whereClause = validFilters.includes(filter) && filter !== "all" ? `WHERE j.status = ?` : "";
|
||||
const params = whereClause ? [filter] : [];
|
||||
|
||||
const jobRows = db.prepare(`
|
||||
const jobRows = db
|
||||
.prepare(`
|
||||
SELECT j.*, mi.name, mi.type, mi.series_name, mi.season_number, mi.episode_number, mi.file_path
|
||||
FROM jobs j
|
||||
LEFT JOIN media_items mi ON mi.id = j.item_id
|
||||
${whereClause}
|
||||
ORDER BY j.created_at DESC
|
||||
LIMIT 200
|
||||
`).all(...params) as (Job & { name: string; type: string; series_name: string | null; season_number: number | null; episode_number: number | null; file_path: string })[];
|
||||
`)
|
||||
.all(...params) as (Job & {
|
||||
name: string;
|
||||
type: string;
|
||||
series_name: string | null;
|
||||
season_number: number | null;
|
||||
episode_number: number | null;
|
||||
file_path: string;
|
||||
})[];
|
||||
|
||||
const jobs = jobRows.map((r) => ({
|
||||
job: r as unknown as Job,
|
||||
item: r.name ? { id: r.item_id, name: r.name, type: r.type, series_name: r.series_name, season_number: r.season_number, episode_number: r.episode_number, file_path: r.file_path } as unknown as MediaItem : null,
|
||||
item: r.name
|
||||
? ({
|
||||
id: r.item_id,
|
||||
name: r.name,
|
||||
type: r.type,
|
||||
series_name: r.series_name,
|
||||
season_number: r.season_number,
|
||||
episode_number: r.episode_number,
|
||||
file_path: r.file_path,
|
||||
} as unknown as MediaItem)
|
||||
: null,
|
||||
}));
|
||||
|
||||
const countRows = db.prepare('SELECT status, COUNT(*) as cnt FROM jobs GROUP BY status').all() as { status: string; cnt: number }[];
|
||||
const countRows = db.prepare("SELECT status, COUNT(*) as cnt FROM jobs GROUP BY status").all() as {
|
||||
status: string;
|
||||
cnt: number;
|
||||
}[];
|
||||
const totalCounts: Record<string, number> = { all: 0, pending: 0, running: 0, done: 0, error: 0 };
|
||||
for (const row of countRows) {
|
||||
totalCounts[row.status] = row.cnt;
|
||||
@@ -121,22 +198,22 @@ function parseId(raw: string | undefined): number | null {
|
||||
|
||||
// ─── Start all pending ────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/start', (c) => {
|
||||
app.post("/start", (c) => {
|
||||
const db = getDb();
|
||||
const pending = db.prepare("SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at").all() as Job[];
|
||||
runSequential(pending).catch((err) => logError('Queue failed:', err));
|
||||
runSequential(pending).catch((err) => logError("Queue failed:", err));
|
||||
return c.json({ ok: true, started: pending.length });
|
||||
});
|
||||
|
||||
// ─── Run single ───────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/job/:id/run', async (c) => {
|
||||
const jobId = parseId(c.req.param('id'));
|
||||
if (jobId == null) return c.json({ error: 'invalid job id' }, 400);
|
||||
app.post("/job/:id/run", async (c) => {
|
||||
const jobId = parseId(c.req.param("id"));
|
||||
if (jobId == null) return c.json({ error: "invalid job id" }, 400);
|
||||
const db = getDb();
|
||||
const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(jobId) as Job | undefined;
|
||||
const job = db.prepare("SELECT * FROM jobs WHERE id = ?").get(jobId) as Job | undefined;
|
||||
if (!job) return c.notFound();
|
||||
if (job.status !== 'pending') {
|
||||
if (job.status !== "pending") {
|
||||
const result = loadJobRow(jobId);
|
||||
if (!result) return c.notFound();
|
||||
return c.json(result);
|
||||
@@ -149,9 +226,9 @@ app.post('/job/:id/run', async (c) => {
|
||||
|
||||
// ─── Cancel ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/job/:id/cancel', (c) => {
|
||||
const jobId = parseId(c.req.param('id'));
|
||||
if (jobId == null) return c.json({ error: 'invalid job id' }, 400);
|
||||
app.post("/job/:id/cancel", (c) => {
|
||||
const jobId = parseId(c.req.param("id"));
|
||||
if (jobId == null) return c.json({ error: "invalid job id" }, 400);
|
||||
const db = getDb();
|
||||
db.prepare("DELETE FROM jobs WHERE id = ? AND status = 'pending'").run(jobId);
|
||||
return c.json({ ok: true });
|
||||
@@ -159,18 +236,20 @@ app.post('/job/:id/cancel', (c) => {
|
||||
|
||||
// ─── Clear queue ──────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/clear', (c) => {
|
||||
app.post("/clear", (c) => {
|
||||
const db = getDb();
|
||||
db.prepare(`
|
||||
db
|
||||
.prepare(`
|
||||
UPDATE review_plans SET status = 'pending', reviewed_at = NULL
|
||||
WHERE item_id IN (SELECT item_id FROM jobs WHERE status = 'pending')
|
||||
AND status = 'approved'
|
||||
`).run();
|
||||
`)
|
||||
.run();
|
||||
const result = db.prepare("DELETE FROM jobs WHERE status = 'pending'").run();
|
||||
return c.json({ ok: true, cleared: result.changes });
|
||||
});
|
||||
|
||||
app.post('/clear-completed', (c) => {
|
||||
app.post("/clear-completed", (c) => {
|
||||
const db = getDb();
|
||||
const result = db.prepare("DELETE FROM jobs WHERE status IN ('done', 'error')").run();
|
||||
return c.json({ ok: true, cleared: result.changes });
|
||||
@@ -178,26 +257,34 @@ app.post('/clear-completed', (c) => {
|
||||
|
||||
// ─── SSE ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/events', (c) => {
|
||||
app.get("/events", (c) => {
|
||||
return stream(c, async (s) => {
|
||||
c.header('Content-Type', 'text/event-stream');
|
||||
c.header('Cache-Control', 'no-cache');
|
||||
c.header("Content-Type", "text/event-stream");
|
||||
c.header("Cache-Control", "no-cache");
|
||||
|
||||
const queue: string[] = [];
|
||||
let resolve: (() => void) | null = null;
|
||||
const listener = (data: string) => { queue.push(data); resolve?.(); };
|
||||
const listener = (data: string) => {
|
||||
queue.push(data);
|
||||
resolve?.();
|
||||
};
|
||||
|
||||
jobListeners.add(listener);
|
||||
s.onAbort(() => { jobListeners.delete(listener); });
|
||||
s.onAbort(() => {
|
||||
jobListeners.delete(listener);
|
||||
});
|
||||
|
||||
try {
|
||||
while (!s.closed) {
|
||||
if (queue.length > 0) {
|
||||
await s.write(queue.shift()!);
|
||||
} else {
|
||||
await new Promise<void>((res) => { resolve = res; setTimeout(res, 15_000); });
|
||||
await new Promise<void>((res) => {
|
||||
resolve = res;
|
||||
setTimeout(res, 15_000);
|
||||
});
|
||||
resolve = null;
|
||||
if (queue.length === 0) await s.write(': keepalive\n\n');
|
||||
if (queue.length === 0) await s.write(": keepalive\n\n");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -213,30 +300,34 @@ async function runJob(job: Job): Promise<void> {
|
||||
log(`Job ${job.id} command: ${job.command}`);
|
||||
const db = getDb();
|
||||
|
||||
const itemRow = db.prepare('SELECT file_path FROM media_items WHERE id = ?').get(job.item_id) as { file_path: string } | undefined;
|
||||
const itemRow = db.prepare("SELECT file_path FROM media_items WHERE id = ?").get(job.item_id) as
|
||||
| { file_path: string }
|
||||
| undefined;
|
||||
if (itemRow?.file_path) {
|
||||
try {
|
||||
accessSync(itemRow.file_path, constants.R_OK | constants.W_OK);
|
||||
} catch (fsErr) {
|
||||
const msg = `File not accessible: ${itemRow.file_path}\n${(fsErr as Error).message}`;
|
||||
db.prepare("UPDATE jobs SET status = 'error', output = ?, exit_code = 1, completed_at = datetime('now') WHERE id = ?").run(msg, job.id);
|
||||
emitJobUpdate(job.id, 'error', msg);
|
||||
db
|
||||
.prepare("UPDATE jobs SET status = 'error', output = ?, exit_code = 1, completed_at = datetime('now') WHERE id = ?")
|
||||
.run(msg, job.id);
|
||||
emitJobUpdate(job.id, "error", msg);
|
||||
db.prepare("UPDATE review_plans SET status = 'error' WHERE item_id = ?").run(job.item_id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
emitJobUpdate(job.id, 'running');
|
||||
emitJobUpdate(job.id, "running");
|
||||
|
||||
const outputLines: string[] = [];
|
||||
let pendingFlush = false;
|
||||
let lastFlushAt = 0;
|
||||
let totalSeconds = 0;
|
||||
let lastProgressEmit = 0;
|
||||
const updateOutput = db.prepare('UPDATE jobs SET output = ? WHERE id = ?');
|
||||
const updateOutput = db.prepare("UPDATE jobs SET output = ? WHERE id = ?");
|
||||
|
||||
const flush = (final = false) => {
|
||||
const text = outputLines.join('\n');
|
||||
const text = outputLines.join("\n");
|
||||
const now = Date.now();
|
||||
if (final || now - lastFlushAt > 500) {
|
||||
updateOutput.run(text, job.id);
|
||||
@@ -245,7 +336,7 @@ async function runJob(job: Job): Promise<void> {
|
||||
} else {
|
||||
pendingFlush = true;
|
||||
}
|
||||
emitJobUpdate(job.id, 'running', text);
|
||||
emitJobUpdate(job.id, "running", text);
|
||||
};
|
||||
|
||||
const consumeProgress = (line: string) => {
|
||||
@@ -264,18 +355,18 @@ async function runJob(job: Job): Promise<void> {
|
||||
};
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(['sh', '-c', job.command], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const readStream = async (readable: ReadableStream<Uint8Array>, prefix = '') => {
|
||||
const proc = Bun.spawn(["sh", "-c", job.command], { stdout: "pipe", stderr: "pipe" });
|
||||
const readStream = async (readable: ReadableStream<Uint8Array>, prefix = "") => {
|
||||
const reader = readable.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let buffer = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const parts = buffer.split(/\r\n|\n|\r/);
|
||||
buffer = parts.pop() ?? '';
|
||||
buffer = parts.pop() ?? "";
|
||||
for (const line of parts) {
|
||||
if (!line.trim()) continue;
|
||||
outputLines.push(prefix + line);
|
||||
@@ -288,25 +379,29 @@ async function runJob(job: Job): Promise<void> {
|
||||
consumeProgress(buffer);
|
||||
}
|
||||
} catch (err) {
|
||||
logError(`stream read error (${prefix.trim() || 'stdout'}):`, err);
|
||||
logError(`stream read error (${prefix.trim() || "stdout"}):`, err);
|
||||
}
|
||||
};
|
||||
await Promise.all([readStream(proc.stdout), readStream(proc.stderr, '[stderr] '), proc.exited]);
|
||||
await Promise.all([readStream(proc.stdout), readStream(proc.stderr, "[stderr] "), proc.exited]);
|
||||
const exitCode = await proc.exited;
|
||||
if (pendingFlush) updateOutput.run(outputLines.join('\n'), job.id);
|
||||
if (pendingFlush) updateOutput.run(outputLines.join("\n"), job.id);
|
||||
if (exitCode !== 0) throw new Error(`FFmpeg exited with code ${exitCode}`);
|
||||
|
||||
const fullOutput = outputLines.join('\n');
|
||||
const fullOutput = outputLines.join("\n");
|
||||
|
||||
// Gather sidecar files to record
|
||||
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(job.item_id) as MediaItem | undefined;
|
||||
const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ?').all(job.item_id) as MediaStream[];
|
||||
const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(job.item_id) as MediaItem | undefined;
|
||||
const streams = db.prepare("SELECT * FROM media_streams WHERE item_id = ?").all(job.item_id) as MediaStream[];
|
||||
const files = item && streams.length > 0 ? predictExtractedFiles(item, streams) : [];
|
||||
|
||||
const insertFile = db.prepare('INSERT OR IGNORE INTO subtitle_files (item_id, file_path, language, codec, is_forced, is_hearing_impaired) VALUES (?, ?, ?, ?, ?, ?)');
|
||||
const markJobDone = db.prepare("UPDATE jobs SET status = 'done', exit_code = 0, output = ?, completed_at = datetime('now') WHERE id = ?");
|
||||
const insertFile = db.prepare(
|
||||
"INSERT OR IGNORE INTO subtitle_files (item_id, file_path, language, codec, is_forced, is_hearing_impaired) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
);
|
||||
const markJobDone = db.prepare(
|
||||
"UPDATE jobs SET status = 'done', exit_code = 0, output = ?, completed_at = datetime('now') WHERE id = ?",
|
||||
);
|
||||
const markPlanDone = db.prepare("UPDATE review_plans SET status = 'done' WHERE item_id = ?");
|
||||
const markSubsExtracted = db.prepare('UPDATE review_plans SET subs_extracted = 1 WHERE item_id = ?');
|
||||
const markSubsExtracted = db.prepare("UPDATE review_plans SET subs_extracted = 1 WHERE item_id = ?");
|
||||
|
||||
db.transaction(() => {
|
||||
markJobDone.run(fullOutput, job.id);
|
||||
@@ -318,23 +413,25 @@ async function runJob(job: Job): Promise<void> {
|
||||
})();
|
||||
|
||||
log(`Job ${job.id} completed successfully`);
|
||||
emitJobUpdate(job.id, 'done', fullOutput);
|
||||
emitJobUpdate(job.id, "done", fullOutput);
|
||||
} catch (err) {
|
||||
logError(`Job ${job.id} failed:`, err);
|
||||
const fullOutput = outputLines.join('\n') + '\n' + String(err);
|
||||
db.prepare("UPDATE jobs SET status = 'error', exit_code = 1, output = ?, completed_at = datetime('now') WHERE id = ?").run(fullOutput, job.id);
|
||||
emitJobUpdate(job.id, 'error', fullOutput);
|
||||
const fullOutput = `${outputLines.join("\n")}\n${String(err)}`;
|
||||
db
|
||||
.prepare("UPDATE jobs SET status = 'error', exit_code = 1, output = ?, completed_at = datetime('now') WHERE id = ?")
|
||||
.run(fullOutput, job.id);
|
||||
emitJobUpdate(job.id, "error", fullOutput);
|
||||
db.prepare("UPDATE review_plans SET status = 'error' WHERE item_id = ?").run(job.item_id);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Scheduler ────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/scheduler', (c) => {
|
||||
app.get("/scheduler", (c) => {
|
||||
return c.json(getSchedulerState());
|
||||
});
|
||||
|
||||
app.patch('/scheduler', async (c) => {
|
||||
app.patch("/scheduler", async (c) => {
|
||||
const body = await c.req.json();
|
||||
updateSchedulerState(body);
|
||||
return c.json(getSchedulerState());
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { Hono } from 'hono';
|
||||
import { getDb } from '../db/index';
|
||||
import { existsSync } from "node:fs";
|
||||
import { Hono } from "hono";
|
||||
import { getDb } from "../db/index";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -10,7 +10,7 @@ interface PathInfo {
|
||||
accessible: boolean;
|
||||
}
|
||||
|
||||
app.get('/', (c) => {
|
||||
app.get("/", (c) => {
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.query<{ prefix: string; count: number }, []>(
|
||||
|
||||
@@ -1,62 +1,96 @@
|
||||
import { Hono } from 'hono';
|
||||
import { getDb, getConfig, getAllConfig } from '../db/index';
|
||||
import { analyzeItem, assignTargetOrder } from '../services/analyzer';
|
||||
import { buildCommand } from '../services/ffmpeg';
|
||||
import { normalizeLanguage, getItem, refreshItem, mapStream } from '../services/jellyfin';
|
||||
import { parseId, isOneOf } from '../lib/validate';
|
||||
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '../types';
|
||||
import { Hono } from "hono";
|
||||
import { getAllConfig, getConfig, getDb } from "../db/index";
|
||||
import { isOneOf, parseId } from "../lib/validate";
|
||||
import { analyzeItem, assignTargetOrder } from "../services/analyzer";
|
||||
import { buildCommand } from "../services/ffmpeg";
|
||||
import { getItem, mapStream, normalizeLanguage, refreshItem } from "../services/jellyfin";
|
||||
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from "../types";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function getSubtitleLanguages(): string[] {
|
||||
return JSON.parse(getConfig('subtitle_languages') ?? '["eng","deu","spa"]');
|
||||
return JSON.parse(getConfig("subtitle_languages") ?? '["eng","deu","spa"]');
|
||||
}
|
||||
|
||||
function countsByFilter(db: ReturnType<typeof getDb>): Record<string, number> {
|
||||
const total = (db.prepare('SELECT COUNT(*) as n FROM review_plans').get() as { n: number }).n;
|
||||
const noops = (db.prepare('SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1').get() as { n: number }).n;
|
||||
const pending = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }).n;
|
||||
const approved = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").get() as { n: number }).n;
|
||||
const skipped = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'skipped'").get() as { n: number }).n;
|
||||
const total = (db.prepare("SELECT COUNT(*) as n FROM review_plans").get() as { n: number }).n;
|
||||
const noops = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1").get() as { n: number }).n;
|
||||
const pending = (
|
||||
db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }
|
||||
).n;
|
||||
const approved = (
|
||||
db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").get() as { n: number }
|
||||
).n;
|
||||
const skipped = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'skipped'").get() as { n: number })
|
||||
.n;
|
||||
const done = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'done'").get() as { n: number }).n;
|
||||
const error = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'error'").get() as { n: number }).n;
|
||||
const manual = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE needs_review = 1 AND original_language IS NULL").get() as { n: number }).n;
|
||||
const manual = (
|
||||
db.prepare("SELECT COUNT(*) as n FROM media_items WHERE needs_review = 1 AND original_language IS NULL").get() as {
|
||||
n: number;
|
||||
}
|
||||
).n;
|
||||
return { all: total, needs_action: pending, noop: noops, approved, skipped, done, error, manual };
|
||||
}
|
||||
|
||||
function buildWhereClause(filter: string): string {
|
||||
switch (filter) {
|
||||
case 'needs_action': return "rp.status = 'pending' AND rp.is_noop = 0";
|
||||
case 'noop': return 'rp.is_noop = 1';
|
||||
case 'manual': return 'mi.needs_review = 1 AND mi.original_language IS NULL';
|
||||
case 'approved': return "rp.status = 'approved'";
|
||||
case 'skipped': return "rp.status = 'skipped'";
|
||||
case 'done': return "rp.status = 'done'";
|
||||
case 'error': return "rp.status = 'error'";
|
||||
default: return '1=1';
|
||||
case "needs_action":
|
||||
return "rp.status = 'pending' AND rp.is_noop = 0";
|
||||
case "noop":
|
||||
return "rp.is_noop = 1";
|
||||
case "manual":
|
||||
return "mi.needs_review = 1 AND mi.original_language IS NULL";
|
||||
case "approved":
|
||||
return "rp.status = 'approved'";
|
||||
case "skipped":
|
||||
return "rp.status = 'skipped'";
|
||||
case "done":
|
||||
return "rp.status = 'done'";
|
||||
case "error":
|
||||
return "rp.status = 'error'";
|
||||
default:
|
||||
return "1=1";
|
||||
}
|
||||
}
|
||||
|
||||
type RawRow = MediaItem & {
|
||||
plan_id: number | null; plan_status: string | null; is_noop: number | null;
|
||||
plan_notes: string | null; reviewed_at: string | null; plan_created_at: string | null;
|
||||
remove_count: number; keep_count: number;
|
||||
plan_id: number | null;
|
||||
plan_status: string | null;
|
||||
is_noop: number | null;
|
||||
plan_notes: string | null;
|
||||
reviewed_at: string | null;
|
||||
plan_created_at: string | null;
|
||||
remove_count: number;
|
||||
keep_count: number;
|
||||
};
|
||||
|
||||
function rowToPlan(r: RawRow): ReviewPlan | null {
|
||||
if (r.plan_id == null) return null;
|
||||
return { id: r.plan_id, item_id: r.id, status: r.plan_status ?? 'pending', is_noop: r.is_noop ?? 0, notes: r.plan_notes, reviewed_at: r.reviewed_at, created_at: r.plan_created_at ?? '' } as ReviewPlan;
|
||||
return {
|
||||
id: r.plan_id,
|
||||
item_id: r.id,
|
||||
status: r.plan_status ?? "pending",
|
||||
is_noop: r.is_noop ?? 0,
|
||||
notes: r.plan_notes,
|
||||
reviewed_at: r.reviewed_at,
|
||||
created_at: r.plan_created_at ?? "",
|
||||
} as ReviewPlan;
|
||||
}
|
||||
|
||||
function loadItemDetail(db: ReturnType<typeof getDb>, itemId: number) {
|
||||
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(itemId) as MediaItem | undefined;
|
||||
const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(itemId) as MediaItem | undefined;
|
||||
if (!item) return { item: null, streams: [], plan: null, decisions: [], command: null };
|
||||
|
||||
const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(itemId) as MediaStream[];
|
||||
const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(itemId) as ReviewPlan | undefined | null;
|
||||
const decisions = plan ? db.prepare('SELECT * FROM stream_decisions WHERE plan_id = ?').all(plan.id) as StreamDecision[] : [];
|
||||
const streams = db
|
||||
.prepare("SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index")
|
||||
.all(itemId) as MediaStream[];
|
||||
const plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(itemId) as ReviewPlan | undefined | null;
|
||||
const decisions = plan
|
||||
? (db.prepare("SELECT * FROM stream_decisions WHERE plan_id = ?").all(plan.id) as StreamDecision[])
|
||||
: [];
|
||||
|
||||
const command = plan && !plan.is_noop ? buildCommand(item, streams, decisions) : null;
|
||||
|
||||
@@ -69,36 +103,57 @@ function loadItemDetail(db: ReturnType<typeof getDb>, itemId: number) {
|
||||
* survive stream-id changes when Jellyfin re-probes metadata.
|
||||
*/
|
||||
function titleKey(s: { type: string; language: string | null; stream_index: number; title: string | null }): string {
|
||||
return `${s.type}|${s.language ?? ''}|${s.stream_index}|${s.title ?? ''}`;
|
||||
return `${s.type}|${s.language ?? ""}|${s.stream_index}|${s.title ?? ""}`;
|
||||
}
|
||||
|
||||
function reanalyze(db: ReturnType<typeof getDb>, itemId: number, preservedTitles?: Map<string, string>): void {
|
||||
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(itemId) as MediaItem;
|
||||
const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(itemId) as MediaItem;
|
||||
if (!item) return;
|
||||
|
||||
const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(itemId) as MediaStream[];
|
||||
const streams = db
|
||||
.prepare("SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index")
|
||||
.all(itemId) as MediaStream[];
|
||||
const subtitleLanguages = getSubtitleLanguages();
|
||||
const audioLanguages: string[] = JSON.parse(getConfig('audio_languages') ?? '[]');
|
||||
const analysis = analyzeItem({ original_language: item.original_language, needs_review: item.needs_review, container: item.container }, streams, { subtitleLanguages, audioLanguages });
|
||||
const audioLanguages: string[] = JSON.parse(getConfig("audio_languages") ?? "[]");
|
||||
const analysis = analyzeItem(
|
||||
{ original_language: item.original_language, needs_review: item.needs_review, container: item.container },
|
||||
streams,
|
||||
{ subtitleLanguages, audioLanguages },
|
||||
);
|
||||
|
||||
db.prepare(`
|
||||
db
|
||||
.prepare(`
|
||||
INSERT INTO review_plans (item_id, status, is_noop, confidence, apple_compat, job_type, notes)
|
||||
VALUES (?, 'pending', ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(item_id) DO UPDATE SET status = 'pending', is_noop = excluded.is_noop, confidence = excluded.confidence, apple_compat = excluded.apple_compat, job_type = excluded.job_type, notes = excluded.notes
|
||||
`).run(itemId, analysis.is_noop ? 1 : 0, analysis.confidence, analysis.apple_compat, analysis.job_type, analysis.notes.length > 0 ? analysis.notes.join('\n') : null);
|
||||
`)
|
||||
.run(
|
||||
itemId,
|
||||
analysis.is_noop ? 1 : 0,
|
||||
analysis.confidence,
|
||||
analysis.apple_compat,
|
||||
analysis.job_type,
|
||||
analysis.notes.length > 0 ? analysis.notes.join("\n") : null,
|
||||
);
|
||||
|
||||
const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number };
|
||||
const plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number };
|
||||
|
||||
// Preserve existing custom_titles: prefer by stream_id (streams unchanged);
|
||||
// fall back to titleKey match (streams regenerated after rescan).
|
||||
const byStreamId = new Map<number, string | null>(
|
||||
(db.prepare('SELECT stream_id, custom_title FROM stream_decisions WHERE plan_id = ?').all(plan.id) as { stream_id: number; custom_title: string | null }[])
|
||||
.map((r) => [r.stream_id, r.custom_title])
|
||||
(
|
||||
db.prepare("SELECT stream_id, custom_title FROM stream_decisions WHERE plan_id = ?").all(plan.id) as {
|
||||
stream_id: number;
|
||||
custom_title: string | null;
|
||||
}[]
|
||||
).map((r) => [r.stream_id, r.custom_title]),
|
||||
);
|
||||
const streamById = new Map(streams.map(s => [s.id, s] as const));
|
||||
const streamById = new Map(streams.map((s) => [s.id, s] as const));
|
||||
|
||||
db.prepare('DELETE FROM stream_decisions WHERE plan_id = ?').run(plan.id);
|
||||
const insertDecision = db.prepare('INSERT INTO stream_decisions (plan_id, stream_id, action, target_index, custom_title, transcode_codec) VALUES (?, ?, ?, ?, ?, ?)');
|
||||
db.prepare("DELETE FROM stream_decisions WHERE plan_id = ?").run(plan.id);
|
||||
const insertDecision = db.prepare(
|
||||
"INSERT INTO stream_decisions (plan_id, stream_id, action, target_index, custom_title, transcode_codec) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
);
|
||||
for (const dec of analysis.decisions) {
|
||||
let customTitle = byStreamId.get(dec.stream_id) ?? null;
|
||||
if (!customTitle && preservedTitles) {
|
||||
@@ -114,50 +169,68 @@ function reanalyze(db: ReturnType<typeof getDb>, itemId: number, preservedTitles
|
||||
* recompute is_noop without wiping user-chosen actions or custom_titles.
|
||||
*/
|
||||
function recomputePlanAfterToggle(db: ReturnType<typeof getDb>, itemId: number): void {
|
||||
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(itemId) as MediaItem | undefined;
|
||||
const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(itemId) as MediaItem | undefined;
|
||||
if (!item) return;
|
||||
const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(itemId) as MediaStream[];
|
||||
const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number } | undefined;
|
||||
const streams = db
|
||||
.prepare("SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index")
|
||||
.all(itemId) as MediaStream[];
|
||||
const plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number } | undefined;
|
||||
if (!plan) return;
|
||||
const decisions = db.prepare('SELECT stream_id, action, target_index, transcode_codec FROM stream_decisions WHERE plan_id = ?').all(plan.id) as {
|
||||
stream_id: number; action: 'keep' | 'remove'; target_index: number | null; transcode_codec: string | null
|
||||
const decisions = db
|
||||
.prepare("SELECT stream_id, action, target_index, transcode_codec FROM stream_decisions WHERE plan_id = ?")
|
||||
.all(plan.id) as {
|
||||
stream_id: number;
|
||||
action: "keep" | "remove";
|
||||
target_index: number | null;
|
||||
transcode_codec: string | null;
|
||||
}[];
|
||||
|
||||
const origLang = item.original_language ? normalizeLanguage(item.original_language) : null;
|
||||
const audioLanguages: string[] = JSON.parse(getConfig('audio_languages') ?? '[]');
|
||||
const audioLanguages: string[] = JSON.parse(getConfig("audio_languages") ?? "[]");
|
||||
|
||||
// Re-assign target_index based on current actions
|
||||
const decWithIdx = decisions.map(d => ({ stream_id: d.stream_id, action: d.action, target_index: null as number | null, transcode_codec: d.transcode_codec }));
|
||||
const decWithIdx = decisions.map((d) => ({
|
||||
stream_id: d.stream_id,
|
||||
action: d.action,
|
||||
target_index: null as number | null,
|
||||
transcode_codec: d.transcode_codec,
|
||||
}));
|
||||
assignTargetOrder(streams, decWithIdx, origLang, audioLanguages);
|
||||
|
||||
const updateIdx = db.prepare('UPDATE stream_decisions SET target_index = ? WHERE plan_id = ? AND stream_id = ?');
|
||||
const updateIdx = db.prepare("UPDATE stream_decisions SET target_index = ? WHERE plan_id = ? AND stream_id = ?");
|
||||
for (const d of decWithIdx) updateIdx.run(d.target_index, plan.id, d.stream_id);
|
||||
|
||||
// Recompute is_noop: audio removed OR reordered OR subs exist OR transcode needed
|
||||
const anyAudioRemoved = streams.some(s => s.type === 'Audio' && decWithIdx.find(d => d.stream_id === s.id)?.action === 'remove');
|
||||
const hasSubs = streams.some(s => s.type === 'Subtitle');
|
||||
const needsTranscode = decWithIdx.some(d => d.transcode_codec != null && d.action === 'keep');
|
||||
const anyAudioRemoved = streams.some(
|
||||
(s) => s.type === "Audio" && decWithIdx.find((d) => d.stream_id === s.id)?.action === "remove",
|
||||
);
|
||||
const hasSubs = streams.some((s) => s.type === "Subtitle");
|
||||
const needsTranscode = decWithIdx.some((d) => d.transcode_codec != null && d.action === "keep");
|
||||
|
||||
const keptAudio = streams
|
||||
.filter(s => s.type === 'Audio' && decWithIdx.find(d => d.stream_id === s.id)?.action === 'keep')
|
||||
.filter((s) => s.type === "Audio" && decWithIdx.find((d) => d.stream_id === s.id)?.action === "keep")
|
||||
.sort((a, b) => a.stream_index - b.stream_index);
|
||||
let audioOrderChanged = false;
|
||||
for (let i = 0; i < keptAudio.length; i++) {
|
||||
const dec = decWithIdx.find(d => d.stream_id === keptAudio[i].id);
|
||||
if (dec?.target_index !== i) { audioOrderChanged = true; break; }
|
||||
const dec = decWithIdx.find((d) => d.stream_id === keptAudio[i].id);
|
||||
if (dec?.target_index !== i) {
|
||||
audioOrderChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const isNoop = !anyAudioRemoved && !audioOrderChanged && !hasSubs && !needsTranscode;
|
||||
db.prepare('UPDATE review_plans SET is_noop = ? WHERE id = ?').run(isNoop ? 1 : 0, plan.id);
|
||||
db.prepare("UPDATE review_plans SET is_noop = ? WHERE id = ?").run(isNoop ? 1 : 0, plan.id);
|
||||
}
|
||||
|
||||
// ─── Pipeline: summary ───────────────────────────────────────────────────────
|
||||
|
||||
app.get('/pipeline', (c) => {
|
||||
app.get("/pipeline", (c) => {
|
||||
const db = getDb();
|
||||
const jellyfinUrl = getConfig('jellyfin_url') ?? '';
|
||||
const jellyfinUrl = getConfig("jellyfin_url") ?? "";
|
||||
|
||||
const review = db.prepare(`
|
||||
const review = db
|
||||
.prepare(`
|
||||
SELECT rp.*, mi.name, mi.series_name, mi.series_jellyfin_id,
|
||||
mi.jellyfin_id,
|
||||
mi.season_number, mi.episode_number, mi.type, mi.container,
|
||||
@@ -169,9 +242,11 @@ app.get('/pipeline', (c) => {
|
||||
CASE rp.confidence WHEN 'high' THEN 0 ELSE 1 END,
|
||||
COALESCE(mi.series_name, mi.name),
|
||||
mi.season_number, mi.episode_number
|
||||
`).all();
|
||||
`)
|
||||
.all();
|
||||
|
||||
const queued = db.prepare(`
|
||||
const queued = db
|
||||
.prepare(`
|
||||
SELECT j.*, mi.name, mi.series_name, mi.type,
|
||||
rp.job_type, rp.apple_compat
|
||||
FROM jobs j
|
||||
@@ -179,18 +254,22 @@ app.get('/pipeline', (c) => {
|
||||
JOIN review_plans rp ON rp.item_id = j.item_id
|
||||
WHERE j.status = 'pending'
|
||||
ORDER BY j.created_at
|
||||
`).all();
|
||||
`)
|
||||
.all();
|
||||
|
||||
const processing = db.prepare(`
|
||||
const processing = db
|
||||
.prepare(`
|
||||
SELECT j.*, mi.name, mi.series_name, mi.type,
|
||||
rp.job_type, rp.apple_compat
|
||||
FROM jobs j
|
||||
JOIN media_items mi ON mi.id = j.item_id
|
||||
JOIN review_plans rp ON rp.item_id = j.item_id
|
||||
WHERE j.status = 'running'
|
||||
`).all();
|
||||
`)
|
||||
.all();
|
||||
|
||||
const done = db.prepare(`
|
||||
const done = db
|
||||
.prepare(`
|
||||
SELECT j.*, mi.name, mi.series_name, mi.type,
|
||||
rp.job_type, rp.apple_compat
|
||||
FROM jobs j
|
||||
@@ -199,24 +278,27 @@ app.get('/pipeline', (c) => {
|
||||
WHERE j.status IN ('done', 'error')
|
||||
ORDER BY j.completed_at DESC
|
||||
LIMIT 50
|
||||
`).all();
|
||||
`)
|
||||
.all();
|
||||
|
||||
const noops = db.prepare('SELECT COUNT(*) as count FROM review_plans WHERE is_noop = 1').get() as { count: number };
|
||||
const noops = db.prepare("SELECT COUNT(*) as count FROM review_plans WHERE is_noop = 1").get() as { count: number };
|
||||
|
||||
// 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);
|
||||
const reasonsByPlan = new Map<number, string[]>();
|
||||
if (planIds.length > 0) {
|
||||
const placeholders = planIds.map(() => '?').join(',');
|
||||
const allReasons = db.prepare(`
|
||||
const placeholders = planIds.map(() => "?").join(",");
|
||||
const allReasons = db
|
||||
.prepare(`
|
||||
SELECT DISTINCT sd.plan_id, ms.codec, sd.transcode_codec
|
||||
FROM stream_decisions sd
|
||||
JOIN media_streams ms ON ms.id = sd.stream_id
|
||||
WHERE sd.plan_id IN (${placeholders}) AND sd.transcode_codec IS NOT NULL
|
||||
`).all(...planIds) as { plan_id: number; codec: string | null; transcode_codec: string }[];
|
||||
`)
|
||||
.all(...planIds) as { plan_id: number; codec: string | null; transcode_codec: string }[];
|
||||
for (const r of allReasons) {
|
||||
if (!reasonsByPlan.has(r.plan_id)) reasonsByPlan.set(r.plan_id, []);
|
||||
reasonsByPlan.get(r.plan_id)!.push(`${(r.codec ?? '').toUpperCase()} → ${r.transcode_codec.toUpperCase()}`);
|
||||
reasonsByPlan.get(r.plan_id)!.push(`${(r.codec ?? "").toUpperCase()} → ${r.transcode_codec.toUpperCase()}`);
|
||||
}
|
||||
}
|
||||
for (const item of review as { id: number; transcode_reasons?: string[] }[]) {
|
||||
@@ -228,12 +310,13 @@ app.get('/pipeline', (c) => {
|
||||
|
||||
// ─── List ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/', (c) => {
|
||||
app.get("/", (c) => {
|
||||
const db = getDb();
|
||||
const filter = c.req.query('filter') ?? 'all';
|
||||
const filter = c.req.query("filter") ?? "all";
|
||||
const where = buildWhereClause(filter);
|
||||
|
||||
const movieRows = db.prepare(`
|
||||
const movieRows = db
|
||||
.prepare(`
|
||||
SELECT mi.*, rp.id as plan_id, rp.status as plan_status, rp.is_noop, rp.notes as plan_notes,
|
||||
rp.reviewed_at, rp.created_at as plan_created_at,
|
||||
COUNT(CASE WHEN sd.action = 'remove' THEN 1 END) as remove_count,
|
||||
@@ -243,11 +326,18 @@ app.get('/', (c) => {
|
||||
LEFT JOIN stream_decisions sd ON sd.plan_id = rp.id
|
||||
WHERE mi.type = 'Movie' AND ${where}
|
||||
GROUP BY mi.id ORDER BY mi.name LIMIT 500
|
||||
`).all() as RawRow[];
|
||||
`)
|
||||
.all() as RawRow[];
|
||||
|
||||
const movies = movieRows.map((r) => ({ item: r as unknown as MediaItem, plan: rowToPlan(r), removeCount: r.remove_count, keepCount: r.keep_count }));
|
||||
const movies = movieRows.map((r) => ({
|
||||
item: r as unknown as MediaItem,
|
||||
plan: rowToPlan(r),
|
||||
removeCount: r.remove_count,
|
||||
keepCount: r.keep_count,
|
||||
}));
|
||||
|
||||
const series = db.prepare(`
|
||||
const series = db
|
||||
.prepare(`
|
||||
SELECT COALESCE(mi.series_jellyfin_id, mi.series_name) as series_key, mi.series_name,
|
||||
MAX(mi.original_language) as original_language,
|
||||
COUNT(DISTINCT mi.season_number) as season_count, COUNT(mi.id) as episode_count,
|
||||
@@ -262,7 +352,8 @@ app.get('/', (c) => {
|
||||
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
||||
WHERE mi.type = 'Episode' AND ${where}
|
||||
GROUP BY series_key ORDER BY mi.series_name
|
||||
`).all();
|
||||
`)
|
||||
.all();
|
||||
|
||||
const totalCounts = countsByFilter(db);
|
||||
return c.json({ movies, series, filter, totalCounts });
|
||||
@@ -270,11 +361,12 @@ app.get('/', (c) => {
|
||||
|
||||
// ─── Series episodes ──────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/series/:seriesKey/episodes', (c) => {
|
||||
app.get("/series/:seriesKey/episodes", (c) => {
|
||||
const db = getDb();
|
||||
const seriesKey = decodeURIComponent(c.req.param('seriesKey'));
|
||||
const seriesKey = decodeURIComponent(c.req.param("seriesKey"));
|
||||
|
||||
const rows = db.prepare(`
|
||||
const rows = db
|
||||
.prepare(`
|
||||
SELECT mi.*, rp.id as plan_id, rp.status as plan_status, rp.is_noop, rp.notes as plan_notes,
|
||||
rp.reviewed_at, rp.created_at as plan_created_at,
|
||||
COUNT(CASE WHEN sd.action = 'remove' THEN 1 END) as remove_count, 0 as keep_count
|
||||
@@ -284,7 +376,8 @@ app.get('/series/:seriesKey/episodes', (c) => {
|
||||
WHERE mi.type = 'Episode'
|
||||
AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?))
|
||||
GROUP BY mi.id ORDER BY mi.season_number, mi.episode_number
|
||||
`).all(seriesKey, seriesKey) as RawRow[];
|
||||
`)
|
||||
.all(seriesKey, seriesKey) as RawRow[];
|
||||
|
||||
const seasonMap = new Map<number | null, unknown[]>();
|
||||
for (const r of rows) {
|
||||
@@ -299,9 +392,11 @@ app.get('/series/:seriesKey/episodes', (c) => {
|
||||
season,
|
||||
episodes,
|
||||
noopCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.is_noop).length,
|
||||
actionCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.status === 'pending' && !e.plan.is_noop).length,
|
||||
approvedCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.status === 'approved').length,
|
||||
doneCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.status === 'done').length,
|
||||
actionCount: (episodes as { plan: ReviewPlan | null }[]).filter(
|
||||
(e) => e.plan?.status === "pending" && !e.plan.is_noop,
|
||||
).length,
|
||||
approvedCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.status === "approved").length,
|
||||
doneCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.status === "done").length,
|
||||
}));
|
||||
|
||||
return c.json({ seasons });
|
||||
@@ -309,63 +404,78 @@ app.get('/series/:seriesKey/episodes', (c) => {
|
||||
|
||||
// ─── Approve series ───────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/series/:seriesKey/approve-all', (c) => {
|
||||
app.post("/series/:seriesKey/approve-all", (c) => {
|
||||
const db = getDb();
|
||||
const seriesKey = decodeURIComponent(c.req.param('seriesKey'));
|
||||
const pending = db.prepare(`
|
||||
const seriesKey = decodeURIComponent(c.req.param("seriesKey"));
|
||||
const pending = db
|
||||
.prepare(`
|
||||
SELECT rp.*, mi.id as item_id FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id
|
||||
WHERE mi.type = 'Episode' AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?))
|
||||
AND rp.status = 'pending' AND rp.is_noop = 0
|
||||
`).all(seriesKey, seriesKey) as (ReviewPlan & { item_id: number })[];
|
||||
`)
|
||||
.all(seriesKey, seriesKey) as (ReviewPlan & { item_id: number })[];
|
||||
for (const plan of pending) {
|
||||
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
||||
const { item, streams, decisions } = loadItemDetail(db, plan.item_id);
|
||||
if (item) db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')").run(plan.item_id, buildCommand(item, streams, decisions));
|
||||
if (item)
|
||||
db
|
||||
.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')")
|
||||
.run(plan.item_id, buildCommand(item, streams, decisions));
|
||||
}
|
||||
return c.json({ ok: true, count: pending.length });
|
||||
});
|
||||
|
||||
// ─── Approve season ───────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/season/:seriesKey/:season/approve-all', (c) => {
|
||||
app.post("/season/:seriesKey/:season/approve-all", (c) => {
|
||||
const db = getDb();
|
||||
const seriesKey = decodeURIComponent(c.req.param('seriesKey'));
|
||||
const season = Number.parseInt(c.req.param('season') ?? '', 10);
|
||||
if (!Number.isFinite(season)) return c.json({ error: 'invalid season' }, 400);
|
||||
const pending = db.prepare(`
|
||||
const seriesKey = decodeURIComponent(c.req.param("seriesKey"));
|
||||
const season = Number.parseInt(c.req.param("season") ?? "", 10);
|
||||
if (!Number.isFinite(season)) return c.json({ error: "invalid season" }, 400);
|
||||
const pending = db
|
||||
.prepare(`
|
||||
SELECT rp.*, mi.id as item_id FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id
|
||||
WHERE mi.type = 'Episode' AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?))
|
||||
AND mi.season_number = ? AND rp.status = 'pending' AND rp.is_noop = 0
|
||||
`).all(seriesKey, seriesKey, season) as (ReviewPlan & { item_id: number })[];
|
||||
`)
|
||||
.all(seriesKey, seriesKey, season) as (ReviewPlan & { item_id: number })[];
|
||||
for (const plan of pending) {
|
||||
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
||||
const { item, streams, decisions } = loadItemDetail(db, plan.item_id);
|
||||
if (item) db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')").run(plan.item_id, buildCommand(item, streams, decisions));
|
||||
if (item)
|
||||
db
|
||||
.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')")
|
||||
.run(plan.item_id, buildCommand(item, streams, decisions));
|
||||
}
|
||||
return c.json({ ok: true, count: pending.length });
|
||||
});
|
||||
|
||||
// ─── Approve all ──────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/approve-all', (c) => {
|
||||
app.post("/approve-all", (c) => {
|
||||
const db = getDb();
|
||||
const pending = db.prepare(
|
||||
"SELECT rp.*, mi.id as item_id FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE rp.status = 'pending' AND rp.is_noop = 0"
|
||||
).all() as (ReviewPlan & { item_id: number })[];
|
||||
const pending = db
|
||||
.prepare(
|
||||
"SELECT rp.*, mi.id as item_id FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE rp.status = 'pending' AND rp.is_noop = 0",
|
||||
)
|
||||
.all() as (ReviewPlan & { item_id: number })[];
|
||||
for (const plan of pending) {
|
||||
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
||||
const { item, streams, decisions } = loadItemDetail(db, plan.item_id);
|
||||
if (item) db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')").run(plan.item_id, buildCommand(item, streams, decisions));
|
||||
if (item)
|
||||
db
|
||||
.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')")
|
||||
.run(plan.item_id, buildCommand(item, streams, decisions));
|
||||
}
|
||||
return c.json({ ok: true, count: pending.length });
|
||||
});
|
||||
|
||||
// ─── Detail ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/:id', (c) => {
|
||||
app.get("/:id", (c) => {
|
||||
const db = getDb();
|
||||
const id = parseId(c.req.param('id'));
|
||||
if (id == null) return c.json({ error: 'invalid id' }, 400);
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (id == null) return c.json({ error: "invalid id" }, 400);
|
||||
const detail = loadItemDetail(db, id);
|
||||
if (!detail.item) return c.notFound();
|
||||
return c.json(detail);
|
||||
@@ -373,13 +483,14 @@ app.get('/:id', (c) => {
|
||||
|
||||
// ─── Override language ────────────────────────────────────────────────────────
|
||||
|
||||
app.patch('/:id/language', async (c) => {
|
||||
app.patch("/:id/language", async (c) => {
|
||||
const db = getDb();
|
||||
const id = parseId(c.req.param('id'));
|
||||
if (id == null) return c.json({ error: 'invalid id' }, 400);
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (id == null) return c.json({ error: "invalid id" }, 400);
|
||||
const body = await c.req.json<{ language: string | null }>();
|
||||
const lang = body.language || null;
|
||||
db.prepare("UPDATE media_items SET original_language = ?, orig_lang_source = 'manual', needs_review = 0 WHERE id = ?")
|
||||
db
|
||||
.prepare("UPDATE media_items SET original_language = ?, orig_lang_source = 'manual', needs_review = 0 WHERE id = ?")
|
||||
.run(lang ? normalizeLanguage(lang) : null, id);
|
||||
reanalyze(db, id);
|
||||
const detail = loadItemDetail(db, id);
|
||||
@@ -389,16 +500,18 @@ app.patch('/:id/language', async (c) => {
|
||||
|
||||
// ─── Edit stream title ────────────────────────────────────────────────────────
|
||||
|
||||
app.patch('/:id/stream/:streamId/title', async (c) => {
|
||||
app.patch("/:id/stream/:streamId/title", async (c) => {
|
||||
const db = getDb();
|
||||
const itemId = parseId(c.req.param('id'));
|
||||
const streamId = parseId(c.req.param('streamId'));
|
||||
if (itemId == null || streamId == null) return c.json({ error: 'invalid id' }, 400);
|
||||
const itemId = parseId(c.req.param("id"));
|
||||
const streamId = parseId(c.req.param("streamId"));
|
||||
if (itemId == null || streamId == null) return c.json({ error: "invalid id" }, 400);
|
||||
const body = await c.req.json<{ title: string }>();
|
||||
const title = (body.title ?? '').trim() || null;
|
||||
const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number } | undefined;
|
||||
const title = (body.title ?? "").trim() || null;
|
||||
const plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number } | undefined;
|
||||
if (!plan) return c.notFound();
|
||||
db.prepare('UPDATE stream_decisions SET custom_title = ? WHERE plan_id = ? AND stream_id = ?').run(title, plan.id, streamId);
|
||||
db
|
||||
.prepare("UPDATE stream_decisions SET custom_title = ? WHERE plan_id = ? AND stream_id = ?")
|
||||
.run(title, plan.id, streamId);
|
||||
const detail = loadItemDetail(db, itemId);
|
||||
if (!detail.item) return c.notFound();
|
||||
return c.json(detail);
|
||||
@@ -406,26 +519,30 @@ app.patch('/:id/stream/:streamId/title', async (c) => {
|
||||
|
||||
// ─── Toggle stream action ─────────────────────────────────────────────────────
|
||||
|
||||
app.patch('/:id/stream/:streamId', async (c) => {
|
||||
app.patch("/:id/stream/:streamId", async (c) => {
|
||||
const db = getDb();
|
||||
const itemId = parseId(c.req.param('id'));
|
||||
const streamId = parseId(c.req.param('streamId'));
|
||||
if (itemId == null || streamId == null) return c.json({ error: 'invalid id' }, 400);
|
||||
const itemId = parseId(c.req.param("id"));
|
||||
const streamId = parseId(c.req.param("streamId"));
|
||||
if (itemId == null || streamId == null) return c.json({ error: "invalid id" }, 400);
|
||||
|
||||
const body = await c.req.json<{ action: unknown }>().catch(() => ({ action: null }));
|
||||
if (!isOneOf(body.action, ['keep', 'remove'] as const)) {
|
||||
if (!isOneOf(body.action, ["keep", "remove"] as const)) {
|
||||
return c.json({ error: 'action must be "keep" or "remove"' }, 400);
|
||||
}
|
||||
const action: 'keep' | 'remove' = body.action;
|
||||
const action: "keep" | "remove" = body.action;
|
||||
|
||||
// Only audio streams can be toggled — subtitles are always removed (extracted to sidecar)
|
||||
const stream = db.prepare('SELECT type, item_id FROM media_streams WHERE id = ?').get(streamId) as { type: string; item_id: number } | undefined;
|
||||
if (!stream || stream.item_id !== itemId) return c.json({ error: 'stream not found on item' }, 404);
|
||||
if (stream.type === 'Subtitle') return c.json({ error: 'Subtitle streams cannot be toggled' }, 400);
|
||||
const stream = db.prepare("SELECT type, item_id FROM media_streams WHERE id = ?").get(streamId) as
|
||||
| { type: string; item_id: number }
|
||||
| undefined;
|
||||
if (!stream || stream.item_id !== itemId) return c.json({ error: "stream not found on item" }, 404);
|
||||
if (stream.type === "Subtitle") return c.json({ error: "Subtitle streams cannot be toggled" }, 400);
|
||||
|
||||
const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number } | undefined;
|
||||
const plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number } | undefined;
|
||||
if (!plan) return c.notFound();
|
||||
db.prepare('UPDATE stream_decisions SET action = ? WHERE plan_id = ? AND stream_id = ?').run(action, plan.id, streamId);
|
||||
db
|
||||
.prepare("UPDATE stream_decisions SET action = ? WHERE plan_id = ? AND stream_id = ?")
|
||||
.run(action, plan.id, streamId);
|
||||
|
||||
recomputePlanAfterToggle(db, itemId);
|
||||
|
||||
@@ -436,63 +553,94 @@ app.patch('/:id/stream/:streamId', async (c) => {
|
||||
|
||||
// ─── Approve ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/:id/approve', (c) => {
|
||||
app.post("/:id/approve", (c) => {
|
||||
const db = getDb();
|
||||
const id = parseId(c.req.param('id'));
|
||||
if (id == null) return c.json({ error: 'invalid id' }, 400);
|
||||
const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(id) as ReviewPlan | undefined;
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (id == null) return c.json({ error: "invalid id" }, 400);
|
||||
const plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(id) as ReviewPlan | undefined;
|
||||
if (!plan) return c.notFound();
|
||||
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
||||
if (!plan.is_noop) {
|
||||
const { item, streams, decisions } = loadItemDetail(db, id);
|
||||
if (item) db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')").run(id, buildCommand(item, streams, decisions));
|
||||
if (item)
|
||||
db
|
||||
.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')")
|
||||
.run(id, buildCommand(item, streams, decisions));
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Unapprove ───────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/:id/unapprove', (c) => {
|
||||
// ─── Retry failed job ─────────────────────────────────────────────────────────
|
||||
|
||||
app.post("/:id/retry", (c) => {
|
||||
const db = getDb();
|
||||
const id = parseId(c.req.param('id'));
|
||||
if (id == null) return c.json({ error: 'invalid id' }, 400);
|
||||
const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(id) as ReviewPlan | undefined;
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (id == null) return c.json({ error: "invalid id" }, 400);
|
||||
const plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(id) as ReviewPlan | undefined;
|
||||
if (!plan) return c.notFound();
|
||||
if (plan.status !== 'approved') return c.json({ ok: false, error: 'Can only unapprove items with status approved' }, 409);
|
||||
if (plan.status !== "error") return c.json({ ok: false, error: "Only failed plans can be retried" }, 409);
|
||||
|
||||
// Clear old errored/done jobs for this item so the queue starts clean
|
||||
db.prepare("DELETE FROM jobs WHERE item_id = ? AND status IN ('error', 'done')").run(id);
|
||||
|
||||
// Rebuild the command from the current decisions (streams may have been edited)
|
||||
const { item, command } = loadItemDetail(db, id);
|
||||
if (!item || !command) return c.json({ ok: false, error: "Cannot rebuild command" }, 400);
|
||||
|
||||
db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'audio', 'pending')").run(id, command);
|
||||
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/:id/unapprove", (c) => {
|
||||
const db = getDb();
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (id == null) return c.json({ error: "invalid id" }, 400);
|
||||
const plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(id) as ReviewPlan | undefined;
|
||||
if (!plan) return c.notFound();
|
||||
if (plan.status !== "approved")
|
||||
return c.json({ ok: false, error: "Can only unapprove items with status approved" }, 409);
|
||||
// Only allow if the associated job hasn't started yet
|
||||
const job = db.prepare("SELECT * FROM jobs WHERE item_id = ? ORDER BY created_at DESC LIMIT 1").get(id) as { id: number; status: string } | undefined;
|
||||
if (job && job.status !== 'pending') return c.json({ ok: false, error: 'Job already started — cannot unapprove' }, 409);
|
||||
const job = db.prepare("SELECT * FROM jobs WHERE item_id = ? ORDER BY created_at DESC LIMIT 1").get(id) as
|
||||
| { id: number; status: string }
|
||||
| undefined;
|
||||
if (job && job.status !== "pending")
|
||||
return c.json({ ok: false, error: "Job already started — cannot unapprove" }, 409);
|
||||
// Delete the pending job and revert plan status
|
||||
if (job) db.prepare('DELETE FROM jobs WHERE id = ?').run(job.id);
|
||||
if (job) db.prepare("DELETE FROM jobs WHERE id = ?").run(job.id);
|
||||
db.prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE id = ?").run(plan.id);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Skip / Unskip ───────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/:id/skip', (c) => {
|
||||
app.post("/:id/skip", (c) => {
|
||||
const db = getDb();
|
||||
const id = parseId(c.req.param('id'));
|
||||
if (id == null) return c.json({ error: 'invalid id' }, 400);
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (id == null) return c.json({ error: "invalid id" }, 400);
|
||||
db.prepare("UPDATE review_plans SET status = 'skipped', reviewed_at = datetime('now') WHERE item_id = ?").run(id);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/:id/unskip', (c) => {
|
||||
app.post("/:id/unskip", (c) => {
|
||||
const db = getDb();
|
||||
const id = parseId(c.req.param('id'));
|
||||
if (id == null) return c.json({ error: 'invalid id' }, 400);
|
||||
db.prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE item_id = ? AND status = 'skipped'").run(id);
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (id == null) return c.json({ error: "invalid id" }, 400);
|
||||
db
|
||||
.prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE item_id = ? AND status = 'skipped'")
|
||||
.run(id);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Rescan ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/:id/rescan', async (c) => {
|
||||
app.post("/:id/rescan", async (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;
|
||||
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 cfg = getAllConfig();
|
||||
@@ -505,13 +653,21 @@ app.post('/:id/rescan', async (c) => {
|
||||
// Snapshot custom_titles keyed by stable properties, since replacing
|
||||
// media_streams cascades away all stream_decisions.
|
||||
const preservedTitles = new Map<string, string>();
|
||||
const oldRows = db.prepare(`
|
||||
const oldRows = db
|
||||
.prepare(`
|
||||
SELECT ms.type, ms.language, ms.stream_index, ms.title, sd.custom_title
|
||||
FROM stream_decisions sd
|
||||
JOIN media_streams ms ON ms.id = sd.stream_id
|
||||
JOIN review_plans rp ON rp.id = sd.plan_id
|
||||
WHERE rp.item_id = ? AND sd.custom_title IS NOT NULL
|
||||
`).all(id) as { type: string; language: string | null; stream_index: number; title: string | null; custom_title: string }[];
|
||||
`)
|
||||
.all(id) as {
|
||||
type: string;
|
||||
language: string | null;
|
||||
stream_index: number;
|
||||
title: string | null;
|
||||
custom_title: string;
|
||||
}[];
|
||||
for (const r of oldRows) {
|
||||
preservedTitles.set(titleKey(r), r.custom_title);
|
||||
}
|
||||
@@ -523,11 +679,26 @@ app.post('/:id/rescan', async (c) => {
|
||||
title, is_default, is_forced, is_hearing_impaired, channels, channel_layout, bit_rate, sample_rate)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
db.prepare('DELETE FROM media_streams WHERE item_id = ?').run(id);
|
||||
db.prepare("DELETE FROM media_streams WHERE item_id = ?").run(id);
|
||||
for (const jStream of fresh.MediaStreams ?? []) {
|
||||
if (jStream.IsExternal) continue; // skip external subs — not embedded in container
|
||||
const s = mapStream(jStream);
|
||||
insertStream.run(id, s.stream_index, s.type, s.codec, s.language, s.language_display, s.title, s.is_default, s.is_forced, s.is_hearing_impaired, s.channels, s.channel_layout, s.bit_rate, s.sample_rate);
|
||||
insertStream.run(
|
||||
id,
|
||||
s.stream_index,
|
||||
s.type,
|
||||
s.codec,
|
||||
s.language,
|
||||
s.language_display,
|
||||
s.title,
|
||||
s.is_default,
|
||||
s.is_forced,
|
||||
s.is_hearing_impaired,
|
||||
s.channels,
|
||||
s.channel_layout,
|
||||
s.bit_rate,
|
||||
s.sample_rate,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -539,16 +710,17 @@ app.post('/:id/rescan', async (c) => {
|
||||
|
||||
// ─── Pipeline: approve up to here ────────────────────────────────────────────
|
||||
|
||||
app.post('/approve-up-to/:id', (c) => {
|
||||
const targetId = parseId(c.req.param('id'));
|
||||
if (targetId == null) return c.json({ error: 'invalid id' }, 400);
|
||||
app.post("/approve-up-to/:id", (c) => {
|
||||
const targetId = parseId(c.req.param("id"));
|
||||
if (targetId == null) return c.json({ error: "invalid id" }, 400);
|
||||
const db = getDb();
|
||||
|
||||
const target = db.prepare('SELECT id FROM review_plans WHERE id = ?').get(targetId) as { id: number } | undefined;
|
||||
if (!target) return c.json({ error: 'Plan not found' }, 404);
|
||||
const target = db.prepare("SELECT id FROM review_plans WHERE id = ?").get(targetId) as { id: number } | undefined;
|
||||
if (!target) return c.json({ error: "Plan not found" }, 404);
|
||||
|
||||
// Get all pending plans sorted by confidence (high first), then name
|
||||
const pendingPlans = db.prepare(`
|
||||
const pendingPlans = db
|
||||
.prepare(`
|
||||
SELECT rp.id
|
||||
FROM review_plans rp
|
||||
JOIN media_items mi ON mi.id = rp.item_id
|
||||
@@ -559,7 +731,8 @@ app.post('/approve-up-to/:id', (c) => {
|
||||
mi.season_number,
|
||||
mi.episode_number,
|
||||
mi.name
|
||||
`).all() as { id: number }[];
|
||||
`)
|
||||
.all() as { id: number }[];
|
||||
|
||||
// Find the target and approve everything up to and including it
|
||||
const toApprove: number[] = [];
|
||||
@@ -571,10 +744,14 @@ app.post('/approve-up-to/:id', (c) => {
|
||||
// Batch approve and create jobs
|
||||
for (const planId of toApprove) {
|
||||
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(planId);
|
||||
const planRow = db.prepare('SELECT item_id, job_type FROM review_plans WHERE id = ?').get(planId) as { item_id: number; job_type: string };
|
||||
const planRow = db.prepare("SELECT item_id, job_type FROM review_plans WHERE id = ?").get(planId) as {
|
||||
item_id: number;
|
||||
job_type: string;
|
||||
};
|
||||
const detail = loadItemDetail(db, planRow.item_id);
|
||||
if (detail.item && detail.command) {
|
||||
db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, ?, 'pending')")
|
||||
db
|
||||
.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, ?, 'pending')")
|
||||
.run(planRow.item_id, detail.command, planRow.job_type);
|
||||
}
|
||||
}
|
||||
@@ -584,18 +761,21 @@ app.post('/approve-up-to/:id', (c) => {
|
||||
|
||||
// ─── Pipeline: series language ───────────────────────────────────────────────
|
||||
|
||||
app.patch('/series/:seriesKey/language', async (c) => {
|
||||
const seriesKey = decodeURIComponent(c.req.param('seriesKey'));
|
||||
app.patch("/series/:seriesKey/language", async (c) => {
|
||||
const seriesKey = decodeURIComponent(c.req.param("seriesKey"));
|
||||
const { language } = await c.req.json<{ language: string }>();
|
||||
const db = getDb();
|
||||
|
||||
const items = db.prepare(
|
||||
'SELECT id FROM media_items WHERE series_jellyfin_id = ? OR (series_jellyfin_id IS NULL AND series_name = ?)'
|
||||
).all(seriesKey, seriesKey) as { id: number }[];
|
||||
const items = db
|
||||
.prepare(
|
||||
"SELECT id FROM media_items WHERE series_jellyfin_id = ? OR (series_jellyfin_id IS NULL AND series_name = ?)",
|
||||
)
|
||||
.all(seriesKey, seriesKey) as { id: number }[];
|
||||
|
||||
const normalizedLang = language ? normalizeLanguage(language) : null;
|
||||
for (const item of items) {
|
||||
db.prepare("UPDATE media_items SET original_language = ?, orig_lang_source = 'manual', needs_review = 0 WHERE id = ?")
|
||||
db
|
||||
.prepare("UPDATE media_items SET original_language = ?, orig_lang_source = 'manual', needs_review = 0 WHERE id = ?")
|
||||
.run(normalizedLang, item.id);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Hono } from 'hono';
|
||||
import { stream } from 'hono/streaming';
|
||||
import { getDb, getConfig, setConfig, getAllConfig } from '../db/index';
|
||||
import { getAllItems, getDevItems, extractOriginalLanguage, mapStream, normalizeLanguage } from '../services/jellyfin';
|
||||
import { getOriginalLanguage as radarrLang } from '../services/radarr';
|
||||
import { getOriginalLanguage as sonarrLang } from '../services/sonarr';
|
||||
import { analyzeItem } from '../services/analyzer';
|
||||
import type { MediaItem, MediaStream } from '../types';
|
||||
import { log, warn, error as logError } from '../lib/log';
|
||||
import { Hono } from "hono";
|
||||
import { stream } from "hono/streaming";
|
||||
import { getAllConfig, getConfig, getDb, setConfig } from "../db/index";
|
||||
import { log, error as logError, warn } from "../lib/log";
|
||||
import { analyzeItem } from "../services/analyzer";
|
||||
import { extractOriginalLanguage, getAllItems, getDevItems, mapStream, normalizeLanguage } from "../services/jellyfin";
|
||||
import { getOriginalLanguage as radarrLang } from "../services/radarr";
|
||||
import { getOriginalLanguage as sonarrLang } from "../services/sonarr";
|
||||
import type { MediaStream } from "../types";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -21,45 +21,48 @@ function emitSse(type: string, data: unknown): void {
|
||||
}
|
||||
|
||||
function currentScanLimit(): number | null {
|
||||
const v = getConfig('scan_limit');
|
||||
const v = getConfig("scan_limit");
|
||||
return v ? Number(v) : null;
|
||||
}
|
||||
|
||||
// ─── Status ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/', (c) => {
|
||||
app.get("/", (c) => {
|
||||
const db = getDb();
|
||||
const running = getConfig('scan_running') === '1';
|
||||
const total = (db.prepare('SELECT COUNT(*) as n FROM media_items').get() as { n: number }).n;
|
||||
const scanned = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'scanned'").get() as { n: number }).n;
|
||||
const errors = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'error'").get() as { n: number }).n;
|
||||
const recentItems = db.prepare(
|
||||
'SELECT name, type, scan_status, file_path FROM media_items ORDER BY last_scanned_at DESC LIMIT 50'
|
||||
).all() as { name: string; type: string; scan_status: string; file_path: string }[];
|
||||
const running = getConfig("scan_running") === "1";
|
||||
const total = (db.prepare("SELECT COUNT(*) as n FROM media_items").get() as { n: number }).n;
|
||||
const scanned = (
|
||||
db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'scanned'").get() as { n: number }
|
||||
).n;
|
||||
const errors = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'error'").get() as { n: number })
|
||||
.n;
|
||||
const recentItems = db
|
||||
.prepare("SELECT name, type, scan_status, file_path FROM media_items ORDER BY last_scanned_at DESC LIMIT 50")
|
||||
.all() as { name: string; type: string; scan_status: string; file_path: string }[];
|
||||
|
||||
return c.json({ running, progress: { scanned, total, errors }, recentItems, scanLimit: currentScanLimit() });
|
||||
});
|
||||
|
||||
// ─── Start ────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/start', async (c) => {
|
||||
app.post("/start", async (c) => {
|
||||
const db = getDb();
|
||||
// Atomic claim: only succeed if scan_running is not already '1'.
|
||||
const claim = db.prepare("UPDATE config SET value = '1' WHERE key = 'scan_running' AND value != '1'").run();
|
||||
if (claim.changes === 0) {
|
||||
return c.json({ ok: false, error: 'Scan already running' }, 409);
|
||||
return c.json({ ok: false, error: "Scan already running" }, 409);
|
||||
}
|
||||
|
||||
const body = await c.req.json<{ limit?: number }>().catch(() => ({ limit: undefined }));
|
||||
const formLimit = body.limit ?? null;
|
||||
const envLimit = process.env.SCAN_LIMIT ? Number(process.env.SCAN_LIMIT) : null;
|
||||
const limit = formLimit ?? envLimit ?? null;
|
||||
setConfig('scan_limit', limit != null ? String(limit) : '');
|
||||
setConfig("scan_limit", limit != null ? String(limit) : "");
|
||||
|
||||
runScan(limit).catch((err) => {
|
||||
logError('Scan failed:', err);
|
||||
setConfig('scan_running', '0');
|
||||
emitSse('error', { message: String(err) });
|
||||
logError("Scan failed:", err);
|
||||
setConfig("scan_running", "0");
|
||||
emitSse("error", { message: String(err) });
|
||||
});
|
||||
|
||||
return c.json({ ok: true });
|
||||
@@ -67,19 +70,19 @@ app.post('/start', async (c) => {
|
||||
|
||||
// ─── Stop ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/stop', (c) => {
|
||||
app.post("/stop", (c) => {
|
||||
scanAbort?.abort();
|
||||
setConfig('scan_running', '0');
|
||||
setConfig("scan_running", "0");
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── SSE ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/events', (c) => {
|
||||
app.get("/events", (c) => {
|
||||
return stream(c, async (s) => {
|
||||
c.header('Content-Type', 'text/event-stream');
|
||||
c.header('Cache-Control', 'no-cache');
|
||||
c.header('Connection', 'keep-alive');
|
||||
c.header("Content-Type", "text/event-stream");
|
||||
c.header("Cache-Control", "no-cache");
|
||||
c.header("Connection", "keep-alive");
|
||||
|
||||
const queue: string[] = [];
|
||||
let resolve: (() => void) | null = null;
|
||||
@@ -90,7 +93,9 @@ app.get('/events', (c) => {
|
||||
};
|
||||
|
||||
scanListeners.add(listener);
|
||||
s.onAbort(() => { scanListeners.delete(listener); });
|
||||
s.onAbort(() => {
|
||||
scanListeners.delete(listener);
|
||||
});
|
||||
|
||||
try {
|
||||
while (!s.closed) {
|
||||
@@ -102,7 +107,7 @@ app.get('/events', (c) => {
|
||||
setTimeout(res, 25_000);
|
||||
});
|
||||
resolve = null;
|
||||
if (queue.length === 0) await s.write(': keepalive\n\n');
|
||||
if (queue.length === 0) await s.write(": keepalive\n\n");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -114,25 +119,31 @@ app.get('/events', (c) => {
|
||||
// ─── Core scan logic ──────────────────────────────────────────────────────────
|
||||
|
||||
async function runScan(limit: number | null = null): Promise<void> {
|
||||
log(`Scan started${limit ? ` (limit: ${limit})` : ''}`);
|
||||
log(`Scan started${limit ? ` (limit: ${limit})` : ""}`);
|
||||
scanAbort = new AbortController();
|
||||
const { signal } = scanAbort;
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const db = getDb();
|
||||
|
||||
if (isDev) {
|
||||
db.prepare('DELETE FROM stream_decisions').run();
|
||||
db.prepare('DELETE FROM review_plans').run();
|
||||
db.prepare('DELETE FROM media_streams').run();
|
||||
db.prepare('DELETE FROM media_items').run();
|
||||
// Order matters only if foreign keys are enforced without CASCADE; we
|
||||
// have ON DELETE CASCADE on media_streams/review_plans/stream_decisions/
|
||||
// subtitle_files/jobs, so deleting media_items would be enough. List
|
||||
// them explicitly for clarity and to survive future schema drift.
|
||||
db.prepare("DELETE FROM jobs").run();
|
||||
db.prepare("DELETE FROM subtitle_files").run();
|
||||
db.prepare("DELETE FROM stream_decisions").run();
|
||||
db.prepare("DELETE FROM review_plans").run();
|
||||
db.prepare("DELETE FROM media_streams").run();
|
||||
db.prepare("DELETE FROM media_items").run();
|
||||
}
|
||||
|
||||
const cfg = getAllConfig();
|
||||
const jellyfinCfg = { url: cfg.jellyfin_url, apiKey: cfg.jellyfin_api_key, userId: cfg.jellyfin_user_id };
|
||||
const subtitleLanguages: string[] = JSON.parse(cfg.subtitle_languages ?? '["eng","deu","spa"]');
|
||||
const audioLanguages: string[] = JSON.parse(cfg.audio_languages ?? '[]');
|
||||
const radarrEnabled = cfg.radarr_enabled === '1';
|
||||
const sonarrEnabled = cfg.sonarr_enabled === '1';
|
||||
const audioLanguages: string[] = JSON.parse(cfg.audio_languages ?? "[]");
|
||||
const radarrEnabled = cfg.radarr_enabled === "1";
|
||||
const sonarrEnabled = cfg.sonarr_enabled === "1";
|
||||
|
||||
let processed = 0;
|
||||
let errors = 0;
|
||||
@@ -157,7 +168,7 @@ async function runScan(limit: number | null = null): Promise<void> {
|
||||
scan_status = 'scanned', last_scanned_at = datetime('now')
|
||||
`);
|
||||
|
||||
const deleteStreams = db.prepare('DELETE FROM media_streams WHERE item_id = ?');
|
||||
const deleteStreams = db.prepare("DELETE FROM media_streams WHERE item_id = ?");
|
||||
const insertStream = db.prepare(`
|
||||
INSERT INTO media_streams (
|
||||
item_id, stream_index, type, codec, language, language_display,
|
||||
@@ -181,9 +192,9 @@ async function runScan(limit: number | null = null): Promise<void> {
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(plan_id, stream_id) DO UPDATE SET action = excluded.action, target_index = excluded.target_index, transcode_codec = excluded.transcode_codec
|
||||
`);
|
||||
const getItemByJellyfinId = db.prepare('SELECT id FROM media_items WHERE jellyfin_id = ?');
|
||||
const getPlanByItemId = db.prepare('SELECT id FROM review_plans WHERE item_id = ?');
|
||||
const getStreamsByItemId = db.prepare('SELECT * FROM media_streams WHERE item_id = ?');
|
||||
const getItemByJellyfinId = db.prepare("SELECT id FROM media_items WHERE jellyfin_id = ?");
|
||||
const getPlanByItemId = db.prepare("SELECT id FROM review_plans WHERE item_id = ?");
|
||||
const getStreamsByItemId = db.prepare("SELECT * FROM media_streams WHERE item_id = ?");
|
||||
|
||||
const itemSource = isDev
|
||||
? getDevItems(jellyfinCfg)
|
||||
@@ -199,45 +210,67 @@ async function runScan(limit: number | null = null): Promise<void> {
|
||||
}
|
||||
|
||||
processed++;
|
||||
emitSse('progress', { scanned: processed, total, current_item: jellyfinItem.Name, errors, running: true });
|
||||
emitSse("progress", { scanned: processed, total, current_item: jellyfinItem.Name, errors, running: true });
|
||||
|
||||
try {
|
||||
const providerIds = jellyfinItem.ProviderIds ?? {};
|
||||
const imdbId = providerIds['Imdb'] ?? null;
|
||||
const tmdbId = providerIds['Tmdb'] ?? null;
|
||||
const tvdbId = providerIds['Tvdb'] ?? null;
|
||||
const imdbId = providerIds.Imdb ?? null;
|
||||
const tmdbId = providerIds.Tmdb ?? null;
|
||||
const tvdbId = providerIds.Tvdb ?? null;
|
||||
|
||||
let origLang: string | null = extractOriginalLanguage(jellyfinItem);
|
||||
let origLangSource = 'jellyfin';
|
||||
let origLangSource = "jellyfin";
|
||||
let needsReview = origLang ? 0 : 1;
|
||||
|
||||
if (jellyfinItem.Type === 'Movie' && radarrEnabled && (tmdbId || imdbId)) {
|
||||
const lang = await radarrLang({ url: cfg.radarr_url, apiKey: cfg.radarr_api_key }, { tmdbId: tmdbId ?? undefined, imdbId: imdbId ?? undefined });
|
||||
if (lang) { if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; origLang = lang; origLangSource = 'radarr'; }
|
||||
if (jellyfinItem.Type === "Movie" && radarrEnabled && (tmdbId || imdbId)) {
|
||||
const lang = await radarrLang(
|
||||
{ url: cfg.radarr_url, apiKey: cfg.radarr_api_key },
|
||||
{ tmdbId: tmdbId ?? undefined, imdbId: imdbId ?? undefined },
|
||||
);
|
||||
if (lang) {
|
||||
if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1;
|
||||
origLang = lang;
|
||||
origLangSource = "radarr";
|
||||
}
|
||||
}
|
||||
|
||||
if (jellyfinItem.Type === 'Episode' && sonarrEnabled && tvdbId) {
|
||||
if (jellyfinItem.Type === "Episode" && sonarrEnabled && tvdbId) {
|
||||
const lang = await sonarrLang({ url: cfg.sonarr_url, apiKey: cfg.sonarr_api_key }, tvdbId);
|
||||
if (lang) { if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; origLang = lang; origLangSource = 'sonarr'; }
|
||||
if (lang) {
|
||||
if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1;
|
||||
origLang = lang;
|
||||
origLangSource = "sonarr";
|
||||
}
|
||||
}
|
||||
|
||||
// Compute confidence from source agreement
|
||||
let confidence: 'high' | 'low' = 'low';
|
||||
let confidence: "high" | "low" = "low";
|
||||
if (!origLang) {
|
||||
confidence = 'low'; // unknown language
|
||||
confidence = "low"; // unknown language
|
||||
} else if (needsReview) {
|
||||
confidence = 'low'; // sources disagree
|
||||
confidence = "low"; // sources disagree
|
||||
} else {
|
||||
confidence = 'high'; // language known, no conflicts
|
||||
confidence = "high"; // language known, no conflicts
|
||||
}
|
||||
|
||||
upsertItem.run(
|
||||
jellyfinItem.Id, jellyfinItem.Type === 'Episode' ? 'Episode' : 'Movie',
|
||||
jellyfinItem.Name, jellyfinItem.SeriesName ?? null, jellyfinItem.SeriesId ?? null,
|
||||
jellyfinItem.ParentIndexNumber ?? null, jellyfinItem.IndexNumber ?? null,
|
||||
jellyfinItem.ProductionYear ?? null, jellyfinItem.Path, jellyfinItem.Size ?? null,
|
||||
jellyfinItem.Container ?? null, origLang, origLangSource, needsReview,
|
||||
imdbId, tmdbId, tvdbId
|
||||
jellyfinItem.Id,
|
||||
jellyfinItem.Type === "Episode" ? "Episode" : "Movie",
|
||||
jellyfinItem.Name,
|
||||
jellyfinItem.SeriesName ?? null,
|
||||
jellyfinItem.SeriesId ?? null,
|
||||
jellyfinItem.ParentIndexNumber ?? null,
|
||||
jellyfinItem.IndexNumber ?? null,
|
||||
jellyfinItem.ProductionYear ?? null,
|
||||
jellyfinItem.Path,
|
||||
jellyfinItem.Size ?? null,
|
||||
jellyfinItem.Container ?? null,
|
||||
origLang,
|
||||
origLangSource,
|
||||
needsReview,
|
||||
imdbId,
|
||||
tmdbId,
|
||||
tvdbId,
|
||||
);
|
||||
|
||||
const itemRow = getItemByJellyfinId.get(jellyfinItem.Id) as { id: number };
|
||||
@@ -247,29 +280,62 @@ async function runScan(limit: number | null = null): Promise<void> {
|
||||
for (const jStream of jellyfinItem.MediaStreams ?? []) {
|
||||
if (jStream.IsExternal) continue; // skip external subs — not embedded in container
|
||||
const s = mapStream(jStream);
|
||||
insertStream.run(itemId, s.stream_index, s.type, s.codec, s.language, s.language_display, s.title, s.is_default, s.is_forced, s.is_hearing_impaired, s.channels, s.channel_layout, s.bit_rate, s.sample_rate);
|
||||
insertStream.run(
|
||||
itemId,
|
||||
s.stream_index,
|
||||
s.type,
|
||||
s.codec,
|
||||
s.language,
|
||||
s.language_display,
|
||||
s.title,
|
||||
s.is_default,
|
||||
s.is_forced,
|
||||
s.is_hearing_impaired,
|
||||
s.channels,
|
||||
s.channel_layout,
|
||||
s.bit_rate,
|
||||
s.sample_rate,
|
||||
);
|
||||
}
|
||||
|
||||
const streams = getStreamsByItemId.all(itemId) as MediaStream[];
|
||||
const analysis = analyzeItem({ original_language: origLang, needs_review: needsReview, container: jellyfinItem.Container ?? null }, streams, { subtitleLanguages, audioLanguages });
|
||||
const analysis = analyzeItem(
|
||||
{ original_language: origLang, needs_review: needsReview, container: jellyfinItem.Container ?? null },
|
||||
streams,
|
||||
{ subtitleLanguages, audioLanguages },
|
||||
);
|
||||
// Override base confidence with scan-computed value
|
||||
const finalConfidence = confidence;
|
||||
upsertPlan.run(itemId, analysis.is_noop ? 1 : 0, finalConfidence, analysis.apple_compat, analysis.job_type, analysis.notes.length > 0 ? analysis.notes.join('\n') : null);
|
||||
upsertPlan.run(
|
||||
itemId,
|
||||
analysis.is_noop ? 1 : 0,
|
||||
finalConfidence,
|
||||
analysis.apple_compat,
|
||||
analysis.job_type,
|
||||
analysis.notes.length > 0 ? analysis.notes.join("\n") : null,
|
||||
);
|
||||
const planRow = getPlanByItemId.get(itemId) as { id: number };
|
||||
for (const dec of analysis.decisions) upsertDecision.run(planRow.id, dec.stream_id, dec.action, dec.target_index, dec.transcode_codec);
|
||||
for (const dec of analysis.decisions)
|
||||
upsertDecision.run(planRow.id, dec.stream_id, dec.action, dec.target_index, dec.transcode_codec);
|
||||
|
||||
emitSse('log', { name: jellyfinItem.Name, type: jellyfinItem.Type, status: 'scanned', file: jellyfinItem.Path });
|
||||
emitSse("log", { name: jellyfinItem.Name, type: jellyfinItem.Type, status: "scanned", file: jellyfinItem.Path });
|
||||
} catch (err) {
|
||||
errors++;
|
||||
logError(`Error scanning ${jellyfinItem.Name}:`, err);
|
||||
try { db.prepare("UPDATE media_items SET scan_status = 'error', scan_error = ? WHERE jellyfin_id = ?").run(String(err), jellyfinItem.Id); } catch { /* ignore */ }
|
||||
emitSse('log', { name: jellyfinItem.Name, type: jellyfinItem.Type, status: 'error', file: jellyfinItem.Path });
|
||||
try {
|
||||
db
|
||||
.prepare("UPDATE media_items SET scan_status = 'error', scan_error = ? WHERE jellyfin_id = ?")
|
||||
.run(String(err), jellyfinItem.Id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
emitSse("log", { name: jellyfinItem.Name, type: jellyfinItem.Type, status: "error", file: jellyfinItem.Path });
|
||||
}
|
||||
}
|
||||
|
||||
setConfig('scan_running', '0');
|
||||
setConfig("scan_running", "0");
|
||||
log(`Scan complete: ${processed} scanned, ${errors} errors`);
|
||||
emitSse('complete', { scanned: processed, total, errors });
|
||||
emitSse("complete", { scanned: processed, total, errors });
|
||||
}
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -1,104 +1,106 @@
|
||||
import { Hono } from 'hono';
|
||||
import { setConfig, getAllConfig, getDb, getEnvLockedKeys } from '../db/index';
|
||||
import { testConnection as testJellyfin, getUsers } from '../services/jellyfin';
|
||||
import { testConnection as testRadarr } from '../services/radarr';
|
||||
import { testConnection as testSonarr } from '../services/sonarr';
|
||||
import { Hono } from "hono";
|
||||
import { getAllConfig, getDb, getEnvLockedKeys, setConfig } from "../db/index";
|
||||
import { getUsers, testConnection as testJellyfin } from "../services/jellyfin";
|
||||
import { testConnection as testRadarr } from "../services/radarr";
|
||||
import { testConnection as testSonarr } from "../services/sonarr";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get('/', (c) => {
|
||||
app.get("/", (c) => {
|
||||
const config = getAllConfig();
|
||||
const envLocked = Array.from(getEnvLockedKeys());
|
||||
return c.json({ config, envLocked });
|
||||
});
|
||||
|
||||
app.post('/jellyfin', async (c) => {
|
||||
app.post("/jellyfin", async (c) => {
|
||||
const body = await c.req.json<{ url: string; api_key: string }>();
|
||||
const url = body.url?.replace(/\/$/, '');
|
||||
const url = body.url?.replace(/\/$/, "");
|
||||
const apiKey = body.api_key;
|
||||
|
||||
if (!url || !apiKey) return c.json({ ok: false, error: 'URL and API key are required' }, 400);
|
||||
if (!url || !apiKey) return c.json({ ok: false, error: "URL and API key are required" }, 400);
|
||||
|
||||
const result = await testJellyfin({ url, apiKey });
|
||||
if (!result.ok) return c.json({ ok: false, error: result.error });
|
||||
|
||||
setConfig('jellyfin_url', url);
|
||||
setConfig('jellyfin_api_key', apiKey);
|
||||
setConfig('setup_complete', '1');
|
||||
setConfig("jellyfin_url", url);
|
||||
setConfig("jellyfin_api_key", apiKey);
|
||||
setConfig("setup_complete", "1");
|
||||
|
||||
try {
|
||||
const users = await getUsers({ url, apiKey });
|
||||
const admin = users.find((u) => u.Name === 'admin') ?? users[0];
|
||||
if (admin?.Id) setConfig('jellyfin_user_id', admin.Id);
|
||||
} catch { /* ignore */ }
|
||||
const admin = users.find((u) => u.Name === "admin") ?? users[0];
|
||||
if (admin?.Id) setConfig("jellyfin_user_id", admin.Id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/radarr', async (c) => {
|
||||
app.post("/radarr", async (c) => {
|
||||
const body = await c.req.json<{ url?: string; api_key?: string }>();
|
||||
const url = body.url?.replace(/\/$/, '');
|
||||
const url = body.url?.replace(/\/$/, "");
|
||||
const apiKey = body.api_key;
|
||||
|
||||
if (!url || !apiKey) {
|
||||
setConfig('radarr_enabled', '0');
|
||||
return c.json({ ok: false, error: 'URL and API key are required' }, 400);
|
||||
setConfig("radarr_enabled", "0");
|
||||
return c.json({ ok: false, error: "URL and API key are required" }, 400);
|
||||
}
|
||||
|
||||
const result = await testRadarr({ url, apiKey });
|
||||
if (!result.ok) return c.json({ ok: false, error: result.error });
|
||||
|
||||
setConfig('radarr_url', url);
|
||||
setConfig('radarr_api_key', apiKey);
|
||||
setConfig('radarr_enabled', '1');
|
||||
setConfig("radarr_url", url);
|
||||
setConfig("radarr_api_key", apiKey);
|
||||
setConfig("radarr_enabled", "1");
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/sonarr', async (c) => {
|
||||
app.post("/sonarr", async (c) => {
|
||||
const body = await c.req.json<{ url?: string; api_key?: string }>();
|
||||
const url = body.url?.replace(/\/$/, '');
|
||||
const url = body.url?.replace(/\/$/, "");
|
||||
const apiKey = body.api_key;
|
||||
|
||||
if (!url || !apiKey) {
|
||||
setConfig('sonarr_enabled', '0');
|
||||
return c.json({ ok: false, error: 'URL and API key are required' }, 400);
|
||||
setConfig("sonarr_enabled", "0");
|
||||
return c.json({ ok: false, error: "URL and API key are required" }, 400);
|
||||
}
|
||||
|
||||
const result = await testSonarr({ url, apiKey });
|
||||
if (!result.ok) return c.json({ ok: false, error: result.error });
|
||||
|
||||
setConfig('sonarr_url', url);
|
||||
setConfig('sonarr_api_key', apiKey);
|
||||
setConfig('sonarr_enabled', '1');
|
||||
setConfig("sonarr_url", url);
|
||||
setConfig("sonarr_api_key", apiKey);
|
||||
setConfig("sonarr_enabled", "1");
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/subtitle-languages', async (c) => {
|
||||
app.post("/subtitle-languages", async (c) => {
|
||||
const body = await c.req.json<{ langs: string[] }>();
|
||||
if (body.langs?.length > 0) {
|
||||
setConfig('subtitle_languages', JSON.stringify(body.langs));
|
||||
setConfig("subtitle_languages", JSON.stringify(body.langs));
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/audio-languages', async (c) => {
|
||||
app.post("/audio-languages", async (c) => {
|
||||
const body = await c.req.json<{ langs: string[] }>();
|
||||
setConfig('audio_languages', JSON.stringify(body.langs ?? []));
|
||||
setConfig("audio_languages", JSON.stringify(body.langs ?? []));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/clear-scan', (c) => {
|
||||
app.post("/clear-scan", (c) => {
|
||||
const db = getDb();
|
||||
// Delete children first to avoid slow cascade deletes
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM stream_decisions').run();
|
||||
db.prepare('DELETE FROM jobs').run();
|
||||
db.prepare('DELETE FROM subtitle_files').run();
|
||||
db.prepare('DELETE FROM review_plans').run();
|
||||
db.prepare('DELETE FROM media_streams').run();
|
||||
db.prepare('DELETE FROM media_items').run();
|
||||
db.prepare("DELETE FROM stream_decisions").run();
|
||||
db.prepare("DELETE FROM jobs").run();
|
||||
db.prepare("DELETE FROM subtitle_files").run();
|
||||
db.prepare("DELETE FROM review_plans").run();
|
||||
db.prepare("DELETE FROM media_streams").run();
|
||||
db.prepare("DELETE FROM media_items").run();
|
||||
db.prepare("UPDATE config SET value = '0' WHERE key = 'scan_running'").run();
|
||||
})();
|
||||
return c.json({ ok: true });
|
||||
|
||||
@@ -1,44 +1,67 @@
|
||||
import { Hono } from 'hono';
|
||||
import { getDb, getConfig, getAllConfig } from '../db/index';
|
||||
import { buildExtractOnlyCommand } from '../services/ffmpeg';
|
||||
import { normalizeLanguage, getItem, refreshItem, mapStream } from '../services/jellyfin';
|
||||
import { parseId } from '../lib/validate';
|
||||
import type { MediaItem, MediaStream, SubtitleFile, ReviewPlan, StreamDecision } from '../types';
|
||||
import { unlinkSync } from 'node:fs';
|
||||
import { dirname, resolve as resolvePath, sep } from 'node:path';
|
||||
import { error as logError } from '../lib/log';
|
||||
import { unlinkSync } from "node:fs";
|
||||
import { dirname, resolve as resolvePath, sep } from "node:path";
|
||||
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";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SubListItem {
|
||||
id: number; jellyfin_id: string; type: string; name: string;
|
||||
series_name: string | null; season_number: number | null;
|
||||
episode_number: number | null; year: number | null;
|
||||
original_language: string | null; file_path: string;
|
||||
subs_extracted: number | null; sub_count: number; file_count: number;
|
||||
id: number;
|
||||
jellyfin_id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
series_name: string | null;
|
||||
season_number: number | null;
|
||||
episode_number: number | null;
|
||||
year: number | null;
|
||||
original_language: string | null;
|
||||
file_path: string;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function loadDetail(db: ReturnType<typeof getDb>, itemId: number) {
|
||||
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(itemId) as MediaItem | undefined;
|
||||
const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(itemId) as MediaItem | undefined;
|
||||
if (!item) return null;
|
||||
|
||||
const subtitleStreams = db.prepare("SELECT * FROM media_streams WHERE item_id = ? AND type = 'Subtitle' ORDER BY stream_index").all(itemId) as MediaStream[];
|
||||
const files = db.prepare('SELECT * FROM subtitle_files WHERE item_id = ? ORDER BY file_path').all(itemId) as SubtitleFile[];
|
||||
const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(itemId) as ReviewPlan | undefined;
|
||||
const subtitleStreams = db
|
||||
.prepare("SELECT * FROM media_streams WHERE item_id = ? AND type = 'Subtitle' ORDER BY stream_index")
|
||||
.all(itemId) as MediaStream[];
|
||||
const files = db
|
||||
.prepare("SELECT * FROM subtitle_files WHERE item_id = ? ORDER BY file_path")
|
||||
.all(itemId) as SubtitleFile[];
|
||||
const plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(itemId) as ReviewPlan | undefined;
|
||||
const decisions = plan
|
||||
? db.prepare("SELECT sd.* FROM stream_decisions sd JOIN media_streams ms ON ms.id = sd.stream_id WHERE sd.plan_id = ? AND ms.type = 'Subtitle'").all(plan.id) as StreamDecision[]
|
||||
? (db
|
||||
.prepare(
|
||||
"SELECT sd.* FROM stream_decisions sd JOIN media_streams ms ON ms.id = sd.stream_id WHERE sd.plan_id = ? AND ms.type = 'Subtitle'",
|
||||
)
|
||||
.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 allStreams = db
|
||||
.prepare("SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index")
|
||||
.all(itemId) as MediaStream[];
|
||||
const extractCommand = buildExtractOnlyCommand(item, allStreams);
|
||||
|
||||
return {
|
||||
@@ -56,20 +79,25 @@ function loadDetail(db: ReturnType<typeof getDb>, itemId: number) {
|
||||
|
||||
function buildSubWhere(filter: string): string {
|
||||
switch (filter) {
|
||||
case 'not_extracted': return "sub_count > 0 AND COALESCE(rp.subs_extracted, 0) = 0";
|
||||
case 'extracted': return "rp.subs_extracted = 1";
|
||||
case 'no_subs': return "sub_count = 0";
|
||||
default: return '1=1';
|
||||
case "not_extracted":
|
||||
return "sub_count > 0 AND COALESCE(rp.subs_extracted, 0) = 0";
|
||||
case "extracted":
|
||||
return "rp.subs_extracted = 1";
|
||||
case "no_subs":
|
||||
return "sub_count = 0";
|
||||
default:
|
||||
return "1=1";
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/', (c) => {
|
||||
app.get("/", (c) => {
|
||||
const db = getDb();
|
||||
const filter = c.req.query('filter') ?? 'all';
|
||||
const filter = c.req.query("filter") ?? "all";
|
||||
const where = buildSubWhere(filter);
|
||||
|
||||
// Movies
|
||||
const movieRows = db.prepare(`
|
||||
const movieRows = db
|
||||
.prepare(`
|
||||
SELECT mi.id, mi.jellyfin_id, mi.type, mi.name, mi.series_name, mi.season_number,
|
||||
mi.episode_number, mi.year, mi.original_language, mi.file_path,
|
||||
rp.subs_extracted,
|
||||
@@ -79,10 +107,12 @@ app.get('/', (c) => {
|
||||
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
||||
WHERE mi.type = 'Movie' AND ${where}
|
||||
ORDER BY mi.name LIMIT 500
|
||||
`).all() as SubListItem[];
|
||||
`)
|
||||
.all() as SubListItem[];
|
||||
|
||||
// Series groups
|
||||
const series = db.prepare(`
|
||||
const series = db
|
||||
.prepare(`
|
||||
SELECT COALESCE(mi.series_jellyfin_id, mi.series_name) as series_key,
|
||||
mi.series_name,
|
||||
MAX(mi.original_language) as original_language,
|
||||
@@ -100,14 +130,21 @@ app.get('/', (c) => {
|
||||
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
||||
WHERE ${where}
|
||||
GROUP BY series_key ORDER BY mi.series_name
|
||||
`).all() as SubSeriesGroup[];
|
||||
`)
|
||||
.all() as SubSeriesGroup[];
|
||||
|
||||
const totalAll = (db.prepare('SELECT COUNT(*) as n FROM media_items').get() as { n: number }).n;
|
||||
const totalExtracted = (db.prepare('SELECT COUNT(*) as n FROM review_plans WHERE subs_extracted = 1').get() as { n: number }).n;
|
||||
const totalNoSubs = (db.prepare(`
|
||||
const totalAll = (db.prepare("SELECT COUNT(*) as n FROM media_items").get() as { n: number }).n;
|
||||
const totalExtracted = (
|
||||
db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE subs_extracted = 1").get() as { n: number }
|
||||
).n;
|
||||
const totalNoSubs = (
|
||||
db
|
||||
.prepare(`
|
||||
SELECT COUNT(*) as n FROM media_items mi
|
||||
WHERE NOT EXISTS (SELECT 1 FROM media_streams ms WHERE ms.item_id = mi.id AND ms.type = 'Subtitle')
|
||||
`).get() as { n: number }).n;
|
||||
`)
|
||||
.get() as { n: number }
|
||||
).n;
|
||||
const totalNotExtracted = totalAll - totalExtracted - totalNoSubs;
|
||||
|
||||
return c.json({
|
||||
@@ -120,11 +157,12 @@ app.get('/', (c) => {
|
||||
|
||||
// ─── Series episodes (subtitles) ─────────────────────────────────────────────
|
||||
|
||||
app.get('/series/:seriesKey/episodes', (c) => {
|
||||
app.get("/series/:seriesKey/episodes", (c) => {
|
||||
const db = getDb();
|
||||
const seriesKey = decodeURIComponent(c.req.param('seriesKey'));
|
||||
const seriesKey = decodeURIComponent(c.req.param("seriesKey"));
|
||||
|
||||
const rows = db.prepare(`
|
||||
const rows = db
|
||||
.prepare(`
|
||||
SELECT mi.id, mi.jellyfin_id, mi.type, mi.name, mi.series_name, mi.season_number,
|
||||
mi.episode_number, mi.year, mi.original_language, mi.file_path,
|
||||
rp.subs_extracted,
|
||||
@@ -135,7 +173,8 @@ app.get('/series/:seriesKey/episodes', (c) => {
|
||||
WHERE mi.type = 'Episode'
|
||||
AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?))
|
||||
ORDER BY mi.season_number, mi.episode_number
|
||||
`).all(seriesKey, seriesKey) as SubListItem[];
|
||||
`)
|
||||
.all(seriesKey, seriesKey) as SubListItem[];
|
||||
|
||||
const seasonMap = new Map<number | null, SubListItem[]>();
|
||||
for (const r of rows) {
|
||||
@@ -159,40 +198,55 @@ app.get('/series/:seriesKey/episodes', (c) => {
|
||||
|
||||
// ─── Summary ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CategoryRow { language: string | null; is_forced: number; is_hearing_impaired: number; cnt: number }
|
||||
|
||||
function variantOf(row: { is_forced: number; is_hearing_impaired: number }): 'forced' | 'cc' | 'standard' {
|
||||
if (row.is_forced) return 'forced';
|
||||
if (row.is_hearing_impaired) return 'cc';
|
||||
return 'standard';
|
||||
interface CategoryRow {
|
||||
language: string | null;
|
||||
is_forced: number;
|
||||
is_hearing_impaired: number;
|
||||
cnt: number;
|
||||
}
|
||||
|
||||
function catKey(lang: string | null, variant: string) { return `${lang ?? '__null__'}|${variant}`; }
|
||||
function variantOf(row: { is_forced: number; is_hearing_impaired: number }): "forced" | "cc" | "standard" {
|
||||
if (row.is_forced) return "forced";
|
||||
if (row.is_hearing_impaired) return "cc";
|
||||
return "standard";
|
||||
}
|
||||
|
||||
app.get('/summary', (c) => {
|
||||
function catKey(lang: string | null, variant: string) {
|
||||
return `${lang ?? "__null__"}|${variant}`;
|
||||
}
|
||||
|
||||
app.get("/summary", (c) => {
|
||||
const db = getDb();
|
||||
|
||||
// Embedded count — items with subtitle streams where subs_extracted = 0
|
||||
const embeddedCount = (db.prepare(`
|
||||
const embeddedCount = (
|
||||
db
|
||||
.prepare(`
|
||||
SELECT COUNT(DISTINCT mi.id) as n FROM media_items mi
|
||||
JOIN media_streams ms ON ms.item_id = mi.id AND ms.type = 'Subtitle'
|
||||
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
||||
WHERE COALESCE(rp.subs_extracted, 0) = 0
|
||||
`).get() as { n: number }).n;
|
||||
`)
|
||||
.get() as { n: number }
|
||||
).n;
|
||||
|
||||
// Stream counts by (language, variant)
|
||||
const streamRows = db.prepare(`
|
||||
const streamRows = db
|
||||
.prepare(`
|
||||
SELECT language, is_forced, is_hearing_impaired, COUNT(*) as cnt
|
||||
FROM media_streams WHERE type = 'Subtitle'
|
||||
GROUP BY language, is_forced, is_hearing_impaired
|
||||
`).all() as CategoryRow[];
|
||||
`)
|
||||
.all() as CategoryRow[];
|
||||
|
||||
// File counts by (language, variant)
|
||||
const fileRows = db.prepare(`
|
||||
const fileRows = db
|
||||
.prepare(`
|
||||
SELECT language, is_forced, is_hearing_impaired, COUNT(*) as cnt
|
||||
FROM subtitle_files
|
||||
GROUP BY language, is_forced, is_hearing_impaired
|
||||
`).all() as CategoryRow[];
|
||||
`)
|
||||
.all() as CategoryRow[];
|
||||
|
||||
// Merge into categories
|
||||
const catMap = new Map<string, { language: string | null; variant: string; streamCount: number; fileCount: number }>();
|
||||
@@ -205,23 +259,28 @@ app.get('/summary', (c) => {
|
||||
const v = variantOf(r);
|
||||
const k = catKey(r.language, v);
|
||||
const existing = catMap.get(k);
|
||||
if (existing) { existing.fileCount = r.cnt; }
|
||||
else { catMap.set(k, { language: r.language, variant: v, streamCount: 0, fileCount: r.cnt }); }
|
||||
if (existing) {
|
||||
existing.fileCount = r.cnt;
|
||||
} else {
|
||||
catMap.set(k, { language: r.language, variant: v, streamCount: 0, fileCount: r.cnt });
|
||||
}
|
||||
}
|
||||
const categories = Array.from(catMap.values()).sort((a, b) => {
|
||||
const la = a.language ?? 'zzz';
|
||||
const lb = b.language ?? 'zzz';
|
||||
const la = a.language ?? "zzz";
|
||||
const lb = b.language ?? "zzz";
|
||||
if (la !== lb) return la.localeCompare(lb);
|
||||
return a.variant.localeCompare(b.variant);
|
||||
});
|
||||
|
||||
// Title grouping
|
||||
const titleRows = db.prepare(`
|
||||
const titleRows = db
|
||||
.prepare(`
|
||||
SELECT language, title, COUNT(*) as cnt
|
||||
FROM media_streams WHERE type = 'Subtitle'
|
||||
GROUP BY language, title
|
||||
ORDER BY language, cnt DESC
|
||||
`).all() as { language: string | null; title: string | null; cnt: number }[];
|
||||
`)
|
||||
.all() as { language: string | null; title: string | null; cnt: number }[];
|
||||
|
||||
// Determine canonical title per language (most common)
|
||||
const canonicalByLang = new Map<string | null, string | null>();
|
||||
@@ -237,19 +296,23 @@ app.get('/summary', (c) => {
|
||||
}));
|
||||
|
||||
// Keep languages from config
|
||||
const raw = getConfig('subtitle_languages');
|
||||
const raw = getConfig("subtitle_languages");
|
||||
let keepLanguages: string[] = [];
|
||||
try { keepLanguages = JSON.parse(raw ?? '[]'); } catch { /* empty */ }
|
||||
try {
|
||||
keepLanguages = JSON.parse(raw ?? "[]");
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
return c.json({ embeddedCount, categories, titles, keepLanguages });
|
||||
});
|
||||
|
||||
// ─── Detail ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/:id', (c) => {
|
||||
app.get("/:id", (c) => {
|
||||
const db = getDb();
|
||||
const id = parseId(c.req.param('id'));
|
||||
if (id == null) return c.json({ error: 'invalid id' }, 400);
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (id == null) return c.json({ error: "invalid id" }, 400);
|
||||
const detail = loadDetail(db, id);
|
||||
if (!detail) return c.notFound();
|
||||
return c.json(detail);
|
||||
@@ -257,19 +320,21 @@ app.get('/:id', (c) => {
|
||||
|
||||
// ─── Edit stream language ────────────────────────────────────────────────────
|
||||
|
||||
app.patch('/:id/stream/:streamId/language', async (c) => {
|
||||
app.patch("/:id/stream/:streamId/language", async (c) => {
|
||||
const db = getDb();
|
||||
const itemId = parseId(c.req.param('id'));
|
||||
const streamId = parseId(c.req.param('streamId'));
|
||||
if (itemId == null || streamId == null) return c.json({ error: 'invalid id' }, 400);
|
||||
const itemId = parseId(c.req.param("id"));
|
||||
const streamId = parseId(c.req.param("streamId"));
|
||||
if (itemId == null || streamId == null) return c.json({ error: "invalid id" }, 400);
|
||||
const body = await c.req.json<{ language: string }>();
|
||||
const lang = (body.language ?? '').trim() || null;
|
||||
const lang = (body.language ?? "").trim() || null;
|
||||
|
||||
const stream = db.prepare('SELECT * FROM media_streams WHERE id = ? AND item_id = ?').get(streamId, itemId) as MediaStream | undefined;
|
||||
const stream = db.prepare("SELECT * FROM media_streams WHERE id = ? AND item_id = ?").get(streamId, itemId) as
|
||||
| MediaStream
|
||||
| undefined;
|
||||
if (!stream) return c.notFound();
|
||||
|
||||
const normalized = lang ? normalizeLanguage(lang) : null;
|
||||
db.prepare('UPDATE media_streams SET language = ? WHERE id = ?').run(normalized, streamId);
|
||||
db.prepare("UPDATE media_streams SET language = ? WHERE id = ?").run(normalized, streamId);
|
||||
|
||||
const detail = loadDetail(db, itemId);
|
||||
if (!detail) return c.notFound();
|
||||
@@ -278,17 +343,19 @@ app.patch('/:id/stream/:streamId/language', async (c) => {
|
||||
|
||||
// ─── Edit stream title ──────────────────────────────────────────────────────
|
||||
|
||||
app.patch('/:id/stream/:streamId/title', async (c) => {
|
||||
app.patch("/:id/stream/:streamId/title", async (c) => {
|
||||
const db = getDb();
|
||||
const itemId = parseId(c.req.param('id'));
|
||||
const streamId = parseId(c.req.param('streamId'));
|
||||
if (itemId == null || streamId == null) return c.json({ error: 'invalid id' }, 400);
|
||||
const itemId = parseId(c.req.param("id"));
|
||||
const streamId = parseId(c.req.param("streamId"));
|
||||
if (itemId == null || streamId == null) return c.json({ error: "invalid id" }, 400);
|
||||
const body = await c.req.json<{ title: string }>();
|
||||
const title = (body.title ?? '').trim() || null;
|
||||
const title = (body.title ?? "").trim() || null;
|
||||
|
||||
const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number } | undefined;
|
||||
const plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number } | undefined;
|
||||
if (!plan) return c.notFound();
|
||||
db.prepare('UPDATE stream_decisions SET custom_title = ? WHERE plan_id = ? AND stream_id = ?').run(title, plan.id, streamId);
|
||||
db
|
||||
.prepare("UPDATE stream_decisions SET custom_title = ? WHERE plan_id = ? AND stream_id = ?")
|
||||
.run(title, plan.id, streamId);
|
||||
|
||||
const detail = loadDetail(db, itemId);
|
||||
if (!detail) return c.notFound();
|
||||
@@ -297,22 +364,28 @@ app.patch('/:id/stream/:streamId/title', async (c) => {
|
||||
|
||||
// ─── Extract all ──────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/extract-all', (c) => {
|
||||
app.post("/extract-all", (c) => {
|
||||
const db = getDb();
|
||||
// Find items with subtitle streams that haven't been extracted yet
|
||||
const items = db.prepare(`
|
||||
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[];
|
||||
`)
|
||||
.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 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);
|
||||
db
|
||||
.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'subtitle', 'pending')")
|
||||
.run(item.id, command);
|
||||
queued++;
|
||||
}
|
||||
|
||||
@@ -321,22 +394,26 @@ app.post('/extract-all', (c) => {
|
||||
|
||||
// ─── Extract ─────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/:id/extract', (c) => {
|
||||
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 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;
|
||||
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 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 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);
|
||||
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);
|
||||
db
|
||||
.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, 'subtitle', 'pending')")
|
||||
.run(id, command);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -352,36 +429,46 @@ function isSidecarOfItem(filePath: string, videoPath: string): boolean {
|
||||
return targetDir === videoDir || targetDir.startsWith(videoDir + sep);
|
||||
}
|
||||
|
||||
app.delete('/:id/files/:fileId', (c) => {
|
||||
app.delete("/:id/files/:fileId", (c) => {
|
||||
const db = getDb();
|
||||
const itemId = parseId(c.req.param('id'));
|
||||
const fileId = parseId(c.req.param('fileId'));
|
||||
if (itemId == null || fileId == null) return c.json({ error: 'invalid id' }, 400);
|
||||
const itemId = parseId(c.req.param("id"));
|
||||
const fileId = parseId(c.req.param("fileId"));
|
||||
if (itemId == null || fileId == null) return c.json({ error: "invalid id" }, 400);
|
||||
|
||||
const file = db.prepare('SELECT * FROM subtitle_files WHERE id = ? AND item_id = ?').get(fileId, itemId) as SubtitleFile | undefined;
|
||||
const file = db.prepare("SELECT * FROM subtitle_files WHERE id = ? AND item_id = ?").get(fileId, itemId) as
|
||||
| SubtitleFile
|
||||
| undefined;
|
||||
if (!file) return c.notFound();
|
||||
|
||||
const item = db.prepare('SELECT file_path FROM media_items WHERE id = ?').get(itemId) as { file_path: string } | undefined;
|
||||
const item = db.prepare("SELECT file_path FROM media_items WHERE id = ?").get(itemId) as
|
||||
| { file_path: string }
|
||||
| undefined;
|
||||
if (!item || !isSidecarOfItem(file.file_path, item.file_path)) {
|
||||
logError(`Refusing to delete subtitle file outside media dir: ${file.file_path}`);
|
||||
db.prepare('DELETE FROM subtitle_files WHERE id = ?').run(fileId);
|
||||
return c.json({ ok: false, error: 'file path outside media directory; DB entry removed without touching disk' }, 400);
|
||||
db.prepare("DELETE FROM subtitle_files WHERE id = ?").run(fileId);
|
||||
return c.json({ ok: false, error: "file path outside media directory; DB entry removed without touching disk" }, 400);
|
||||
}
|
||||
|
||||
try { unlinkSync(file.file_path); } catch { /* file may not exist */ }
|
||||
db.prepare('DELETE FROM subtitle_files WHERE id = ?').run(fileId);
|
||||
try {
|
||||
unlinkSync(file.file_path);
|
||||
} catch {
|
||||
/* file may not exist */
|
||||
}
|
||||
db.prepare("DELETE FROM subtitle_files WHERE id = ?").run(fileId);
|
||||
|
||||
const files = db.prepare('SELECT * FROM subtitle_files WHERE item_id = ? ORDER BY file_path').all(itemId) as SubtitleFile[];
|
||||
const files = db
|
||||
.prepare("SELECT * FROM subtitle_files WHERE item_id = ? ORDER BY file_path")
|
||||
.all(itemId) as SubtitleFile[];
|
||||
return c.json({ ok: true, files });
|
||||
});
|
||||
|
||||
// ─── Rescan ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/:id/rescan', async (c) => {
|
||||
app.post("/:id/rescan", async (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;
|
||||
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 cfg = getAllConfig();
|
||||
@@ -396,11 +483,26 @@ app.post('/:id/rescan', async (c) => {
|
||||
title, is_default, is_forced, is_hearing_impaired, channels, channel_layout, bit_rate, sample_rate)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
db.prepare('DELETE FROM media_streams WHERE item_id = ?').run(id);
|
||||
db.prepare("DELETE FROM media_streams WHERE item_id = ?").run(id);
|
||||
for (const jStream of fresh.MediaStreams ?? []) {
|
||||
if (jStream.IsExternal) continue;
|
||||
const s = mapStream(jStream);
|
||||
insertStream.run(id, s.stream_index, s.type, s.codec, s.language, s.language_display, s.title, s.is_default, s.is_forced, s.is_hearing_impaired, s.channels, s.channel_layout, s.bit_rate, s.sample_rate);
|
||||
insertStream.run(
|
||||
id,
|
||||
s.stream_index,
|
||||
s.type,
|
||||
s.codec,
|
||||
s.language,
|
||||
s.language_display,
|
||||
s.title,
|
||||
s.is_default,
|
||||
s.is_forced,
|
||||
s.is_hearing_impaired,
|
||||
s.channels,
|
||||
s.channel_layout,
|
||||
s.bit_rate,
|
||||
s.sample_rate,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,45 +513,57 @@ app.post('/:id/rescan', async (c) => {
|
||||
|
||||
// ─── Batch delete subtitle files ─────────────────────────────────────────────
|
||||
|
||||
app.post('/batch-delete', async (c) => {
|
||||
app.post("/batch-delete", async (c) => {
|
||||
const db = getDb();
|
||||
const body = await c.req.json<{ categories: { language: string | null; variant: 'standard' | 'forced' | 'cc' }[] }>();
|
||||
const body = await c.req.json<{ categories: { language: string | null; variant: "standard" | "forced" | "cc" }[] }>();
|
||||
|
||||
let deleted = 0;
|
||||
for (const cat of body.categories) {
|
||||
const isForced = cat.variant === 'forced' ? 1 : 0;
|
||||
const isHI = cat.variant === 'cc' ? 1 : 0;
|
||||
const isForced = cat.variant === "forced" ? 1 : 0;
|
||||
const isHI = cat.variant === "cc" ? 1 : 0;
|
||||
|
||||
let files: SubtitleFile[];
|
||||
if (cat.language === null) {
|
||||
files = db.prepare(`
|
||||
files = db
|
||||
.prepare(`
|
||||
SELECT * FROM subtitle_files
|
||||
WHERE language IS NULL AND is_forced = ? AND is_hearing_impaired = ?
|
||||
`).all(isForced, isHI) as SubtitleFile[];
|
||||
`)
|
||||
.all(isForced, isHI) as SubtitleFile[];
|
||||
} else {
|
||||
files = db.prepare(`
|
||||
files = db
|
||||
.prepare(`
|
||||
SELECT * FROM subtitle_files
|
||||
WHERE language = ? AND is_forced = ? AND is_hearing_impaired = ?
|
||||
`).all(cat.language, isForced, isHI) as SubtitleFile[];
|
||||
`)
|
||||
.all(cat.language, isForced, isHI) as SubtitleFile[];
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const item = db.prepare('SELECT file_path FROM media_items WHERE id = ?').get(file.item_id) as { file_path: string } | undefined;
|
||||
const item = db.prepare("SELECT file_path FROM media_items WHERE id = ?").get(file.item_id) as
|
||||
| { file_path: string }
|
||||
| undefined;
|
||||
if (item && isSidecarOfItem(file.file_path, item.file_path)) {
|
||||
try { unlinkSync(file.file_path); } catch { /* file may not exist */ }
|
||||
try {
|
||||
unlinkSync(file.file_path);
|
||||
} catch {
|
||||
/* file may not exist */
|
||||
}
|
||||
} else {
|
||||
logError(`Refusing to delete subtitle file outside media dir: ${file.file_path}`);
|
||||
}
|
||||
db.prepare('DELETE FROM subtitle_files WHERE id = ?').run(file.id);
|
||||
db.prepare("DELETE FROM subtitle_files WHERE id = ?").run(file.id);
|
||||
deleted++;
|
||||
}
|
||||
|
||||
// Reset subs_extracted for affected items that now have no subtitle files
|
||||
const affectedItems = new Set(files.map((f) => f.item_id));
|
||||
for (const itemId of affectedItems) {
|
||||
const remaining = (db.prepare('SELECT COUNT(*) as n FROM subtitle_files WHERE item_id = ?').get(itemId) as { n: number }).n;
|
||||
const remaining = (
|
||||
db.prepare("SELECT COUNT(*) as n FROM subtitle_files WHERE item_id = ?").get(itemId) as { n: number }
|
||||
).n;
|
||||
if (remaining === 0) {
|
||||
db.prepare('UPDATE review_plans SET subs_extracted = 0 WHERE item_id = ?').run(itemId);
|
||||
db.prepare("UPDATE review_plans SET subs_extracted = 0 WHERE item_id = ?").run(itemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -459,16 +573,18 @@ app.post('/batch-delete', async (c) => {
|
||||
|
||||
// ─── Normalize titles ────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/normalize-titles', (c) => {
|
||||
app.post("/normalize-titles", (c) => {
|
||||
const db = getDb();
|
||||
|
||||
// Get title groups per language
|
||||
const titleRows = db.prepare(`
|
||||
const titleRows = db
|
||||
.prepare(`
|
||||
SELECT language, title, COUNT(*) as cnt
|
||||
FROM media_streams WHERE type = 'Subtitle'
|
||||
GROUP BY language, title
|
||||
ORDER BY language, cnt DESC
|
||||
`).all() as { language: string | null; title: string | null; cnt: number }[];
|
||||
`)
|
||||
.all() as { language: string | null; title: string | null; cnt: number }[];
|
||||
|
||||
// Find canonical (most common) title per language
|
||||
const canonicalByLang = new Map<string | null, string | null>();
|
||||
@@ -484,31 +600,43 @@ app.post('/normalize-titles', (c) => {
|
||||
// Find all streams matching this language+title and set custom_title on their decisions
|
||||
let streams: { id: number; item_id: number }[];
|
||||
if (r.language === null) {
|
||||
streams = db.prepare(`
|
||||
streams = db
|
||||
.prepare(`
|
||||
SELECT id, item_id FROM media_streams
|
||||
WHERE type = 'Subtitle' AND language IS NULL AND title IS ?
|
||||
`).all(r.title) as { id: number; item_id: number }[];
|
||||
`)
|
||||
.all(r.title) as { id: number; item_id: number }[];
|
||||
} else {
|
||||
streams = db.prepare(`
|
||||
streams = db
|
||||
.prepare(`
|
||||
SELECT id, item_id FROM media_streams
|
||||
WHERE type = 'Subtitle' AND language = ? AND title IS ?
|
||||
`).all(r.language, r.title) as { id: number; item_id: number }[];
|
||||
`)
|
||||
.all(r.language, r.title) as { id: number; item_id: number }[];
|
||||
}
|
||||
|
||||
for (const stream of streams) {
|
||||
// Ensure review_plan exists
|
||||
let plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(stream.item_id) as { id: number } | undefined;
|
||||
let plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(stream.item_id) as
|
||||
| { id: number }
|
||||
| undefined;
|
||||
if (!plan) {
|
||||
db.prepare("INSERT INTO review_plans (item_id, status, is_noop) VALUES (?, 'pending', 0)").run(stream.item_id);
|
||||
plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(stream.item_id) as { id: number };
|
||||
plan = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(stream.item_id) as { id: number };
|
||||
}
|
||||
|
||||
// Upsert stream_decision with custom_title
|
||||
const existing = db.prepare('SELECT id FROM stream_decisions WHERE plan_id = ? AND stream_id = ?').get(plan.id, stream.id);
|
||||
const existing = db
|
||||
.prepare("SELECT id FROM stream_decisions WHERE plan_id = ? AND stream_id = ?")
|
||||
.get(plan.id, stream.id);
|
||||
if (existing) {
|
||||
db.prepare('UPDATE stream_decisions SET custom_title = ? WHERE plan_id = ? AND stream_id = ?').run(canonical, plan.id, stream.id);
|
||||
db
|
||||
.prepare("UPDATE stream_decisions SET custom_title = ? WHERE plan_id = ? AND stream_id = ?")
|
||||
.run(canonical, plan.id, stream.id);
|
||||
} else {
|
||||
db.prepare("INSERT INTO stream_decisions (plan_id, stream_id, action, custom_title) VALUES (?, ?, 'keep', ?)").run(plan.id, stream.id, canonical);
|
||||
db
|
||||
.prepare("INSERT INTO stream_decisions (plan_id, stream_id, action, custom_title) VALUES (?, ?, 'keep', ?)")
|
||||
.run(plan.id, stream.id, canonical);
|
||||
}
|
||||
normalized++;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { join } from 'node:path';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { SCHEMA, DEFAULT_CONFIG } from './schema';
|
||||
import { Database } from "bun:sqlite";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DEFAULT_CONFIG, SCHEMA } from "./schema";
|
||||
|
||||
const dataDir = process.env.DATA_DIR ?? './data';
|
||||
const dataDir = process.env.DATA_DIR ?? "./data";
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const dbPath = join(dataDir, isDev ? 'netfelix-dev.db' : 'netfelix.db');
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const dbPath = join(dataDir, isDev ? "netfelix-dev.db" : "netfelix.db");
|
||||
|
||||
// ─── Env-var → config key mapping ─────────────────────────────────────────────
|
||||
|
||||
const ENV_MAP: Record<string, string> = {
|
||||
jellyfin_url: 'JELLYFIN_URL',
|
||||
jellyfin_api_key: 'JELLYFIN_API_KEY',
|
||||
jellyfin_user_id: 'JELLYFIN_USER_ID',
|
||||
radarr_url: 'RADARR_URL',
|
||||
radarr_api_key: 'RADARR_API_KEY',
|
||||
radarr_enabled: 'RADARR_ENABLED',
|
||||
sonarr_url: 'SONARR_URL',
|
||||
sonarr_api_key: 'SONARR_API_KEY',
|
||||
sonarr_enabled: 'SONARR_ENABLED',
|
||||
subtitle_languages: 'SUBTITLE_LANGUAGES',
|
||||
audio_languages: 'AUDIO_LANGUAGES',
|
||||
|
||||
jellyfin_url: "JELLYFIN_URL",
|
||||
jellyfin_api_key: "JELLYFIN_API_KEY",
|
||||
jellyfin_user_id: "JELLYFIN_USER_ID",
|
||||
radarr_url: "RADARR_URL",
|
||||
radarr_api_key: "RADARR_API_KEY",
|
||||
radarr_enabled: "RADARR_ENABLED",
|
||||
sonarr_url: "SONARR_URL",
|
||||
sonarr_api_key: "SONARR_API_KEY",
|
||||
sonarr_enabled: "SONARR_ENABLED",
|
||||
subtitle_languages: "SUBTITLE_LANGUAGES",
|
||||
audio_languages: "AUDIO_LANGUAGES",
|
||||
};
|
||||
|
||||
/** Read a config key from environment variables (returns null if not set). */
|
||||
@@ -32,9 +31,10 @@ function envValue(key: string): string | null {
|
||||
if (!envKey) return null;
|
||||
const val = process.env[envKey];
|
||||
if (!val) return null;
|
||||
if (key.endsWith('_enabled')) return val === '1' || val.toLowerCase() === 'true' ? '1' : '0';
|
||||
if (key === 'subtitle_languages' || key === 'audio_languages') return JSON.stringify(val.split(',').map((s) => s.trim()));
|
||||
if (key.endsWith('_url')) return val.replace(/\/$/, '');
|
||||
if (key.endsWith("_enabled")) return val === "1" || val.toLowerCase() === "true" ? "1" : "0";
|
||||
if (key === "subtitle_languages" || key === "audio_languages")
|
||||
return JSON.stringify(val.split(",").map((s) => s.trim()));
|
||||
if (key.endsWith("_url")) return val.replace(/\/$/, "");
|
||||
return val;
|
||||
}
|
||||
|
||||
@@ -52,23 +52,49 @@ export function getDb(): Database {
|
||||
_db = new Database(dbPath, { create: true });
|
||||
_db.exec(SCHEMA);
|
||||
// Migrations for columns added after initial release
|
||||
try { _db.exec('ALTER TABLE stream_decisions ADD COLUMN custom_title TEXT'); } catch { /* already exists */ }
|
||||
try { _db.exec('ALTER TABLE review_plans ADD COLUMN subs_extracted INTEGER NOT NULL DEFAULT 0'); } catch { /* already exists */ }
|
||||
try { _db.exec("ALTER TABLE jobs ADD COLUMN job_type TEXT NOT NULL DEFAULT 'audio'"); } catch { /* already exists */ }
|
||||
try {
|
||||
_db.exec("ALTER TABLE stream_decisions ADD COLUMN custom_title TEXT");
|
||||
} catch {
|
||||
/* already exists */
|
||||
}
|
||||
try {
|
||||
_db.exec("ALTER TABLE review_plans ADD COLUMN subs_extracted INTEGER NOT NULL DEFAULT 0");
|
||||
} catch {
|
||||
/* already exists */
|
||||
}
|
||||
try {
|
||||
_db.exec("ALTER TABLE jobs ADD COLUMN job_type TEXT NOT NULL DEFAULT 'audio'");
|
||||
} catch {
|
||||
/* already exists */
|
||||
}
|
||||
// Apple compat pipeline columns
|
||||
try { _db.exec("ALTER TABLE review_plans ADD COLUMN confidence TEXT NOT NULL DEFAULT 'low'"); } catch { /* already exists */ }
|
||||
try { _db.exec('ALTER TABLE review_plans ADD COLUMN apple_compat TEXT'); } catch { /* already exists */ }
|
||||
try { _db.exec("ALTER TABLE review_plans ADD COLUMN job_type TEXT NOT NULL DEFAULT 'copy'"); } catch { /* already exists */ }
|
||||
try { _db.exec('ALTER TABLE stream_decisions ADD COLUMN transcode_codec TEXT'); } catch { /* already exists */ }
|
||||
try {
|
||||
_db.exec("ALTER TABLE review_plans ADD COLUMN confidence TEXT NOT NULL DEFAULT 'low'");
|
||||
} catch {
|
||||
/* already exists */
|
||||
}
|
||||
try {
|
||||
_db.exec("ALTER TABLE review_plans ADD COLUMN apple_compat TEXT");
|
||||
} catch {
|
||||
/* already exists */
|
||||
}
|
||||
try {
|
||||
_db.exec("ALTER TABLE review_plans ADD COLUMN job_type TEXT NOT NULL DEFAULT 'copy'");
|
||||
} catch {
|
||||
/* already exists */
|
||||
}
|
||||
try {
|
||||
_db.exec("ALTER TABLE stream_decisions ADD COLUMN transcode_codec TEXT");
|
||||
} catch {
|
||||
/* already exists */
|
||||
}
|
||||
seedDefaults(_db);
|
||||
|
||||
return _db;
|
||||
}
|
||||
|
||||
function seedDefaults(db: Database): void {
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)'
|
||||
);
|
||||
const insert = db.prepare("INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)");
|
||||
for (const [key, value] of Object.entries(DEFAULT_CONFIG)) {
|
||||
insert.run(key, value);
|
||||
}
|
||||
@@ -79,17 +105,13 @@ export function getConfig(key: string): string | null {
|
||||
const fromEnv = envValue(key);
|
||||
if (fromEnv !== null) return fromEnv;
|
||||
// Auto-complete setup when all required Jellyfin env vars are present
|
||||
if (key === 'setup_complete' && isEnvConfigured()) return '1';
|
||||
const row = getDb()
|
||||
.prepare('SELECT value FROM config WHERE key = ?')
|
||||
.get(key) as { value: string } | undefined;
|
||||
if (key === "setup_complete" && isEnvConfigured()) return "1";
|
||||
const row = getDb().prepare("SELECT value FROM config WHERE key = ?").get(key) as { value: string } | undefined;
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export function setConfig(key: string, value: string): void {
|
||||
getDb()
|
||||
.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)')
|
||||
.run(key, value);
|
||||
getDb().prepare("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)").run(key, value);
|
||||
}
|
||||
|
||||
/** Returns the set of config keys currently overridden by environment variables. */
|
||||
@@ -102,17 +124,14 @@ export function getEnvLockedKeys(): Set<string> {
|
||||
}
|
||||
|
||||
export function getAllConfig(): Record<string, string> {
|
||||
const rows = getDb()
|
||||
.prepare('SELECT key, value FROM config')
|
||||
.all() as { key: string; value: string }[];
|
||||
const result = Object.fromEntries(rows.map((r) => [r.key, r.value ?? '']));
|
||||
const rows = getDb().prepare("SELECT key, value FROM config").all() as { key: string; value: string }[];
|
||||
const result = Object.fromEntries(rows.map((r) => [r.key, r.value ?? ""]));
|
||||
// Apply env overrides on top of DB values
|
||||
for (const key of Object.keys(ENV_MAP)) {
|
||||
const fromEnv = envValue(key);
|
||||
if (fromEnv !== null) result[key] = fromEnv;
|
||||
}
|
||||
// Auto-complete setup when all required Jellyfin env vars are present
|
||||
if (isEnvConfigured()) result.setup_complete = '1';
|
||||
if (isEnvConfigured()) result.setup_complete = "1";
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -110,22 +110,22 @@ CREATE INDEX IF NOT EXISTS idx_jobs_item_id ON jobs(item_id);
|
||||
`;
|
||||
|
||||
export const DEFAULT_CONFIG: Record<string, string> = {
|
||||
setup_complete: '0',
|
||||
jellyfin_url: '',
|
||||
jellyfin_api_key: '',
|
||||
jellyfin_user_id: '',
|
||||
radarr_url: '',
|
||||
radarr_api_key: '',
|
||||
radarr_enabled: '0',
|
||||
sonarr_url: '',
|
||||
sonarr_api_key: '',
|
||||
sonarr_enabled: '0',
|
||||
subtitle_languages: JSON.stringify(['eng', 'deu', 'spa']),
|
||||
audio_languages: '[]',
|
||||
setup_complete: "0",
|
||||
jellyfin_url: "",
|
||||
jellyfin_api_key: "",
|
||||
jellyfin_user_id: "",
|
||||
radarr_url: "",
|
||||
radarr_api_key: "",
|
||||
radarr_enabled: "0",
|
||||
sonarr_url: "",
|
||||
sonarr_api_key: "",
|
||||
sonarr_enabled: "0",
|
||||
subtitle_languages: JSON.stringify(["eng", "deu", "spa"]),
|
||||
audio_languages: "[]",
|
||||
|
||||
scan_running: '0',
|
||||
job_sleep_seconds: '0',
|
||||
schedule_enabled: '0',
|
||||
schedule_start: '01:00',
|
||||
schedule_end: '07:00',
|
||||
scan_running: "0",
|
||||
job_sleep_seconds: "0",
|
||||
schedule_enabled: "0",
|
||||
schedule_start: "01:00",
|
||||
schedule_end: "07:00",
|
||||
};
|
||||
|
||||
@@ -1,70 +1,69 @@
|
||||
import { Hono } from 'hono';
|
||||
import { serveStatic } from 'hono/bun';
|
||||
import { cors } from 'hono/cors';
|
||||
import { getDb, getConfig } from './db/index';
|
||||
import { log } from './lib/log';
|
||||
|
||||
import setupRoutes from './api/setup';
|
||||
import scanRoutes from './api/scan';
|
||||
import reviewRoutes from './api/review';
|
||||
import executeRoutes from './api/execute';
|
||||
import subtitlesRoutes from './api/subtitles';
|
||||
import dashboardRoutes from './api/dashboard';
|
||||
import pathsRoutes from './api/paths';
|
||||
import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import { cors } from "hono/cors";
|
||||
import dashboardRoutes from "./api/dashboard";
|
||||
import executeRoutes from "./api/execute";
|
||||
import pathsRoutes from "./api/paths";
|
||||
import reviewRoutes from "./api/review";
|
||||
import scanRoutes from "./api/scan";
|
||||
import setupRoutes from "./api/setup";
|
||||
import subtitlesRoutes from "./api/subtitles";
|
||||
import { getDb } from "./db/index";
|
||||
import { log } from "./lib/log";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// ─── CORS (dev: Vite on :5173 talks to Hono on :3000) ────────────────────────
|
||||
|
||||
app.use('/api/*', cors({ origin: ['http://localhost:5173', 'http://localhost:3000'] }));
|
||||
app.use("/api/*", cors({ origin: ["http://localhost:5173", "http://localhost:3000"] }));
|
||||
|
||||
// ─── Request logging ──────────────────────────────────────────────────────────
|
||||
|
||||
app.use('/api/*', async (c, next) => {
|
||||
app.use("/api/*", async (c, next) => {
|
||||
const start = Date.now();
|
||||
await next();
|
||||
const ms = Date.now() - start;
|
||||
// Skip noisy SSE/polling endpoints
|
||||
if (c.req.path.endsWith('/events')) return;
|
||||
if (c.req.path.endsWith("/events")) return;
|
||||
log(`${c.req.method} ${c.req.path} → ${c.res.status} (${ms}ms)`);
|
||||
});
|
||||
|
||||
// ─── API routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
import pkg from '../package.json';
|
||||
import pkg from "../package.json";
|
||||
|
||||
app.get('/api/version', (c) => c.json({ version: pkg.version }));
|
||||
app.route('/api/dashboard', dashboardRoutes);
|
||||
app.route('/api/setup', setupRoutes);
|
||||
app.route('/api/scan', scanRoutes);
|
||||
app.route('/api/review', reviewRoutes);
|
||||
app.route('/api/execute', executeRoutes);
|
||||
app.route('/api/subtitles', subtitlesRoutes);
|
||||
app.route('/api/paths', pathsRoutes);
|
||||
app.get("/api/version", (c) => c.json({ version: pkg.version }));
|
||||
app.route("/api/dashboard", dashboardRoutes);
|
||||
app.route("/api/setup", setupRoutes);
|
||||
app.route("/api/scan", scanRoutes);
|
||||
app.route("/api/review", reviewRoutes);
|
||||
app.route("/api/execute", executeRoutes);
|
||||
app.route("/api/subtitles", subtitlesRoutes);
|
||||
app.route("/api/paths", pathsRoutes);
|
||||
|
||||
// ─── Static assets (production: serve Vite build) ────────────────────────────
|
||||
|
||||
app.use('/assets/*', serveStatic({ root: './dist' }));
|
||||
app.use('/favicon.ico', serveStatic({ path: './dist/favicon.ico' }));
|
||||
app.use("/assets/*", serveStatic({ root: "./dist" }));
|
||||
app.use("/favicon.ico", serveStatic({ path: "./dist/favicon.ico" }));
|
||||
|
||||
// ─── SPA fallback ─────────────────────────────────────────────────────────────
|
||||
// All non-API routes serve the React index.html so TanStack Router handles them.
|
||||
|
||||
app.get('*', (c) => {
|
||||
const accept = c.req.header('Accept') ?? '';
|
||||
if (c.req.path.startsWith('/api/')) return c.notFound();
|
||||
app.get("*", (c) => {
|
||||
const _accept = c.req.header("Accept") ?? "";
|
||||
if (c.req.path.startsWith("/api/")) return c.notFound();
|
||||
// In dev the Vite server handles the SPA. In production serve dist/index.html.
|
||||
try {
|
||||
const html = Bun.file('./dist/index.html').text();
|
||||
const html = Bun.file("./dist/index.html").text();
|
||||
return html.then((text) => c.html(text));
|
||||
} catch {
|
||||
return c.text('Run `bun build` first to generate the frontend.', 503);
|
||||
return c.text("Run `bun build` first to generate the frontend.", 503);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Start ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const port = Number(process.env.PORT ?? '3000');
|
||||
const port = Number(process.env.PORT ?? "3000");
|
||||
|
||||
log(`netfelix-audio-fix v${pkg.version} starting on http://localhost:${port}`);
|
||||
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { parseId, isOneOf } from '../validate';
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { isOneOf, parseId } from "../validate";
|
||||
|
||||
describe('parseId', () => {
|
||||
test('returns the integer for valid numeric strings', () => {
|
||||
expect(parseId('42')).toBe(42);
|
||||
expect(parseId('1')).toBe(1);
|
||||
describe("parseId", () => {
|
||||
test("returns the integer for valid numeric strings", () => {
|
||||
expect(parseId("42")).toBe(42);
|
||||
expect(parseId("1")).toBe(1);
|
||||
});
|
||||
|
||||
test('returns null for invalid, negative, zero, or missing ids', () => {
|
||||
expect(parseId('0')).toBe(null);
|
||||
expect(parseId('-1')).toBe(null);
|
||||
expect(parseId('abc')).toBe(null);
|
||||
expect(parseId('')).toBe(null);
|
||||
test("returns null for invalid, negative, zero, or missing ids", () => {
|
||||
expect(parseId("0")).toBe(null);
|
||||
expect(parseId("-1")).toBe(null);
|
||||
expect(parseId("abc")).toBe(null);
|
||||
expect(parseId("")).toBe(null);
|
||||
expect(parseId(undefined)).toBe(null);
|
||||
});
|
||||
|
||||
test('parses leading integer from mixed strings (parseInt semantics)', () => {
|
||||
expect(parseId('42abc')).toBe(42);
|
||||
test("parses leading integer from mixed strings (parseInt semantics)", () => {
|
||||
expect(parseId("42abc")).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOneOf', () => {
|
||||
test('narrows to allowed string literals', () => {
|
||||
expect(isOneOf('keep', ['keep', 'remove'] as const)).toBe(true);
|
||||
expect(isOneOf('remove', ['keep', 'remove'] as const)).toBe(true);
|
||||
describe("isOneOf", () => {
|
||||
test("narrows to allowed string literals", () => {
|
||||
expect(isOneOf("keep", ["keep", "remove"] as const)).toBe(true);
|
||||
expect(isOneOf("remove", ["keep", "remove"] as const)).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects disallowed values and non-strings', () => {
|
||||
expect(isOneOf('delete', ['keep', 'remove'] as const)).toBe(false);
|
||||
expect(isOneOf(null, ['keep', 'remove'] as const)).toBe(false);
|
||||
expect(isOneOf(42, ['keep', 'remove'] as const)).toBe(false);
|
||||
test("rejects disallowed values and non-strings", () => {
|
||||
expect(isOneOf("delete", ["keep", "remove"] as const)).toBe(false);
|
||||
expect(isOneOf(null, ["keep", "remove"] as const)).toBe(false);
|
||||
expect(isOneOf(42, ["keep", "remove"] as const)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Context } from 'hono';
|
||||
import type { Context } from "hono";
|
||||
|
||||
/** Parse a route param as a positive integer id. Returns null if invalid. */
|
||||
export function parseId(raw: string | undefined): number | null {
|
||||
@@ -22,5 +22,5 @@ export function requireId(c: Context, name: string): number | null {
|
||||
|
||||
/** True if value is one of the allowed strings. */
|
||||
export function isOneOf<T extends string>(value: unknown, allowed: readonly T[]): value is T {
|
||||
return typeof value === 'string' && (allowed as readonly string[]).includes(value);
|
||||
return typeof value === "string" && (allowed as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { analyzeItem } from '../analyzer';
|
||||
import type { MediaStream } from '../../types';
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { MediaStream } from "../../types";
|
||||
import { analyzeItem } from "../analyzer";
|
||||
|
||||
type StreamOverride = Partial<MediaStream> & Pick<MediaStream, 'id' | 'type' | 'stream_index'>;
|
||||
type StreamOverride = Partial<MediaStream> & Pick<MediaStream, "id" | "type" | "stream_index">;
|
||||
|
||||
function stream(o: StreamOverride): MediaStream {
|
||||
return {
|
||||
@@ -22,112 +22,110 @@ function stream(o: StreamOverride): MediaStream {
|
||||
};
|
||||
}
|
||||
|
||||
const ITEM_DEFAULTS = { needs_review: 0 as number, container: 'mkv' as string | null };
|
||||
const ITEM_DEFAULTS = { needs_review: 0 as number, container: "mkv" as string | null };
|
||||
|
||||
describe('analyzeItem — audio keep rules', () => {
|
||||
test('keeps only OG + configured languages, drops others', () => {
|
||||
describe("analyzeItem — audio keep rules", () => {
|
||||
test("keeps only OG + configured languages, drops others", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }),
|
||||
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }),
|
||||
stream({ id: 3, type: 'Audio', stream_index: 2, codec: 'aac', language: 'deu' }),
|
||||
stream({ id: 4, type: 'Audio', stream_index: 3, codec: 'aac', language: 'fra' }),
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }),
|
||||
stream({ id: 3, type: "Audio", stream_index: 2, codec: "aac", language: "deu" }),
|
||||
stream({ id: 4, type: "Audio", stream_index: 3, codec: "aac", language: "fra" }),
|
||||
];
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, {
|
||||
subtitleLanguages: [],
|
||||
audioLanguages: ['deu'],
|
||||
audioLanguages: ["deu"],
|
||||
});
|
||||
const actions = Object.fromEntries(result.decisions.map(d => [d.stream_id, d.action]));
|
||||
expect(actions).toEqual({ 1: 'keep', 2: 'keep', 3: 'keep', 4: 'remove' });
|
||||
const actions = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.action]));
|
||||
expect(actions).toEqual({ 1: "keep", 2: "keep", 3: "keep", 4: "remove" });
|
||||
});
|
||||
|
||||
test('keeps all audio when OG language unknown', () => {
|
||||
test("keeps all audio when OG language unknown", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: 'Audio', stream_index: 0, language: 'eng' }),
|
||||
stream({ id: 2, type: 'Audio', stream_index: 1, language: 'deu' }),
|
||||
stream({ id: 3, type: 'Audio', stream_index: 2, language: 'fra' }),
|
||||
stream({ id: 1, type: "Audio", stream_index: 0, language: "eng" }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, language: "deu" }),
|
||||
stream({ id: 3, type: "Audio", stream_index: 2, language: "fra" }),
|
||||
];
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: null, needs_review: 1 }, streams, {
|
||||
subtitleLanguages: [],
|
||||
audioLanguages: ['deu'],
|
||||
audioLanguages: ["deu"],
|
||||
});
|
||||
expect(result.decisions.every(d => d.action === 'keep')).toBe(true);
|
||||
expect(result.notes.some(n => n.includes('manual review'))).toBe(true);
|
||||
expect(result.decisions.every((d) => d.action === "keep")).toBe(true);
|
||||
expect(result.notes.some((n) => n.includes("manual review"))).toBe(true);
|
||||
});
|
||||
|
||||
test('keeps audio tracks with undetermined language', () => {
|
||||
test("keeps audio tracks with undetermined language", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: 'Audio', stream_index: 0, language: 'eng' }),
|
||||
stream({ id: 2, type: 'Audio', stream_index: 1, language: null }),
|
||||
stream({ id: 1, type: "Audio", stream_index: 0, language: "eng" }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, language: null }),
|
||||
];
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, {
|
||||
subtitleLanguages: [],
|
||||
audioLanguages: [],
|
||||
});
|
||||
const byId = Object.fromEntries(result.decisions.map(d => [d.stream_id, d.action]));
|
||||
expect(byId[2]).toBe('keep');
|
||||
const byId = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.action]));
|
||||
expect(byId[2]).toBe("keep");
|
||||
});
|
||||
|
||||
test('normalizes language codes (ger → deu)', () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: 'Audio', stream_index: 0, language: 'ger' }),
|
||||
];
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'deu' }, streams, {
|
||||
test("normalizes language codes (ger → deu)", () => {
|
||||
const streams = [stream({ id: 1, type: "Audio", stream_index: 0, language: "ger" })];
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "deu" }, streams, {
|
||||
subtitleLanguages: [],
|
||||
audioLanguages: [],
|
||||
});
|
||||
expect(result.decisions[0].action).toBe('keep');
|
||||
expect(result.decisions[0].action).toBe("keep");
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeItem — audio ordering', () => {
|
||||
test('OG first, then additional languages in configured order', () => {
|
||||
describe("analyzeItem — audio ordering", () => {
|
||||
test("OG first, then additional languages in configured order", () => {
|
||||
const streams = [
|
||||
stream({ id: 10, type: 'Audio', stream_index: 0, codec: 'aac', language: 'deu' }),
|
||||
stream({ id: 11, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }),
|
||||
stream({ id: 12, type: 'Audio', stream_index: 2, codec: 'aac', language: 'spa' }),
|
||||
stream({ id: 10, type: "Audio", stream_index: 0, codec: "aac", language: "deu" }),
|
||||
stream({ id: 11, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }),
|
||||
stream({ id: 12, type: "Audio", stream_index: 2, codec: "aac", language: "spa" }),
|
||||
];
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, {
|
||||
subtitleLanguages: [],
|
||||
audioLanguages: ['deu', 'spa'],
|
||||
audioLanguages: ["deu", "spa"],
|
||||
});
|
||||
const byId = Object.fromEntries(result.decisions.map(d => [d.stream_id, d.target_index]));
|
||||
const byId = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.target_index]));
|
||||
expect(byId[11]).toBe(0); // eng (OG) first
|
||||
expect(byId[10]).toBe(1); // deu second
|
||||
expect(byId[12]).toBe(2); // spa third
|
||||
});
|
||||
|
||||
test('audioOrderChanged is_noop=false when OG audio is not first in input', () => {
|
||||
test("audioOrderChanged is_noop=false when OG audio is not first in input", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }),
|
||||
stream({ id: 2, type: 'Audio', stream_index: 1, language: 'deu' }),
|
||||
stream({ id: 3, type: 'Audio', stream_index: 2, language: 'eng' }),
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, language: "deu" }),
|
||||
stream({ id: 3, type: "Audio", stream_index: 2, language: "eng" }),
|
||||
];
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, {
|
||||
subtitleLanguages: [],
|
||||
audioLanguages: ['deu'],
|
||||
audioLanguages: ["deu"],
|
||||
});
|
||||
expect(result.is_noop).toBe(false);
|
||||
});
|
||||
|
||||
test('audioOrderChanged is_noop=true when OG audio is already first', () => {
|
||||
test("audioOrderChanged is_noop=true when OG audio is already first", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }),
|
||||
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }),
|
||||
stream({ id: 3, type: 'Audio', stream_index: 2, codec: 'aac', language: 'deu' }),
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }),
|
||||
stream({ id: 3, type: "Audio", stream_index: 2, codec: "aac", language: "deu" }),
|
||||
];
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, {
|
||||
subtitleLanguages: [],
|
||||
audioLanguages: ['deu'],
|
||||
audioLanguages: ["deu"],
|
||||
});
|
||||
expect(result.is_noop).toBe(true);
|
||||
});
|
||||
|
||||
test('removing an audio track triggers non-noop even if OG first', () => {
|
||||
test("removing an audio track triggers non-noop even if OG first", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'aac', language: 'eng' }),
|
||||
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'fra' }),
|
||||
stream({ id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "eng" }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "fra" }),
|
||||
];
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, {
|
||||
subtitleLanguages: [],
|
||||
audioLanguages: [],
|
||||
});
|
||||
@@ -135,27 +133,27 @@ describe('analyzeItem — audio ordering', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeItem — subtitles & is_noop', () => {
|
||||
test('subtitles are always marked remove (extracted to sidecar)', () => {
|
||||
describe("analyzeItem — subtitles & is_noop", () => {
|
||||
test("subtitles are always marked remove (extracted to sidecar)", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'aac', language: 'eng' }),
|
||||
stream({ id: 2, type: 'Subtitle', stream_index: 1, codec: 'subrip', language: 'eng' }),
|
||||
stream({ id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "eng" }),
|
||||
stream({ id: 2, type: "Subtitle", stream_index: 1, codec: "subrip", language: "eng" }),
|
||||
];
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
|
||||
subtitleLanguages: ['eng'],
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, {
|
||||
subtitleLanguages: ["eng"],
|
||||
audioLanguages: [],
|
||||
});
|
||||
const subDec = result.decisions.find(d => d.stream_id === 2);
|
||||
expect(subDec?.action).toBe('remove');
|
||||
const subDec = result.decisions.find((d) => d.stream_id === 2);
|
||||
expect(subDec?.action).toBe("remove");
|
||||
expect(result.is_noop).toBe(false); // subs present → not noop
|
||||
});
|
||||
|
||||
test('no audio change, no subs → is_noop true', () => {
|
||||
test("no audio change, no subs → is_noop true", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }),
|
||||
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }),
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }),
|
||||
];
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
|
||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, {
|
||||
subtitleLanguages: [],
|
||||
audioLanguages: [],
|
||||
});
|
||||
@@ -163,29 +161,25 @@ describe('analyzeItem — subtitles & is_noop', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeItem — transcode targets', () => {
|
||||
test('DTS on mp4 → transcode to eac3', () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'dts', language: 'eng' }),
|
||||
];
|
||||
const result = analyzeItem({ original_language: 'eng', needs_review: 0, container: 'mp4' }, streams, {
|
||||
describe("analyzeItem — transcode targets", () => {
|
||||
test("DTS on mp4 → transcode to eac3", () => {
|
||||
const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "dts", language: "eng" })];
|
||||
const result = analyzeItem({ original_language: "eng", needs_review: 0, container: "mp4" }, streams, {
|
||||
subtitleLanguages: [],
|
||||
audioLanguages: [],
|
||||
});
|
||||
expect(result.decisions[0].transcode_codec).toBe('eac3');
|
||||
expect(result.job_type).toBe('transcode');
|
||||
expect(result.decisions[0].transcode_codec).toBe("eac3");
|
||||
expect(result.job_type).toBe("transcode");
|
||||
expect(result.is_noop).toBe(false);
|
||||
});
|
||||
|
||||
test('AAC passes through without transcode', () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'aac', language: 'eng' }),
|
||||
];
|
||||
const result = analyzeItem({ original_language: 'eng', needs_review: 0, container: 'mp4' }, streams, {
|
||||
test("AAC passes through without transcode", () => {
|
||||
const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "eng" })];
|
||||
const result = analyzeItem({ original_language: "eng", needs_review: 0, container: "mp4" }, streams, {
|
||||
subtitleLanguages: [],
|
||||
audioLanguages: [],
|
||||
});
|
||||
expect(result.decisions[0].transcode_codec).toBe(null);
|
||||
expect(result.job_type).toBe('copy');
|
||||
expect(result.job_type).toBe("copy");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { buildCommand, buildPipelineCommand, shellQuote, sortKeptStreams, predictExtractedFiles } from '../ffmpeg';
|
||||
import type { MediaItem, MediaStream, StreamDecision } from '../../types';
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { MediaItem, MediaStream, StreamDecision } from "../../types";
|
||||
import { buildCommand, buildPipelineCommand, predictExtractedFiles, shellQuote, sortKeptStreams } from "../ffmpeg";
|
||||
|
||||
function stream(o: Partial<MediaStream> & Pick<MediaStream, 'id' | 'type' | 'stream_index'>): MediaStream {
|
||||
function stream(o: Partial<MediaStream> & Pick<MediaStream, "id" | "type" | "stream_index">): MediaStream {
|
||||
return {
|
||||
item_id: 1,
|
||||
codec: null,
|
||||
@@ -20,7 +20,7 @@ function stream(o: Partial<MediaStream> & Pick<MediaStream, 'id' | 'type' | 'str
|
||||
};
|
||||
}
|
||||
|
||||
function decision(o: Partial<StreamDecision> & Pick<StreamDecision, 'stream_id' | 'action'>): StreamDecision {
|
||||
function decision(o: Partial<StreamDecision> & Pick<StreamDecision, "stream_id" | "action">): StreamDecision {
|
||||
return {
|
||||
id: 0,
|
||||
plan_id: 1,
|
||||
@@ -32,162 +32,178 @@ function decision(o: Partial<StreamDecision> & Pick<StreamDecision, 'stream_id'
|
||||
}
|
||||
|
||||
const ITEM: MediaItem = {
|
||||
id: 1, jellyfin_id: 'x', type: 'Movie', name: 'Test', series_name: null,
|
||||
series_jellyfin_id: null, season_number: null, episode_number: null, year: null,
|
||||
file_path: '/movies/Test.mkv', file_size: null, container: 'mkv',
|
||||
original_language: 'eng', orig_lang_source: 'jellyfin', needs_review: 0,
|
||||
imdb_id: null, tmdb_id: null, tvdb_id: null, scan_status: 'scanned',
|
||||
scan_error: null, last_scanned_at: null, created_at: '',
|
||||
id: 1,
|
||||
jellyfin_id: "x",
|
||||
type: "Movie",
|
||||
name: "Test",
|
||||
series_name: null,
|
||||
series_jellyfin_id: null,
|
||||
season_number: null,
|
||||
episode_number: null,
|
||||
year: null,
|
||||
file_path: "/movies/Test.mkv",
|
||||
file_size: null,
|
||||
container: "mkv",
|
||||
original_language: "eng",
|
||||
orig_lang_source: "jellyfin",
|
||||
needs_review: 0,
|
||||
imdb_id: null,
|
||||
tmdb_id: null,
|
||||
tvdb_id: null,
|
||||
scan_status: "scanned",
|
||||
scan_error: null,
|
||||
last_scanned_at: null,
|
||||
created_at: "",
|
||||
};
|
||||
|
||||
describe('shellQuote', () => {
|
||||
test('wraps plain strings in single quotes', () => {
|
||||
expect(shellQuote('hello')).toBe("'hello'");
|
||||
describe("shellQuote", () => {
|
||||
test("wraps plain strings in single quotes", () => {
|
||||
expect(shellQuote("hello")).toBe("'hello'");
|
||||
});
|
||||
|
||||
test('escapes single quotes safely', () => {
|
||||
test("escapes single quotes safely", () => {
|
||||
expect(shellQuote("it's")).toBe("'it'\\''s'");
|
||||
});
|
||||
|
||||
test('handles paths with spaces', () => {
|
||||
expect(shellQuote('/movies/My Movie.mkv')).toBe("'/movies/My Movie.mkv'");
|
||||
test("handles paths with spaces", () => {
|
||||
expect(shellQuote("/movies/My Movie.mkv")).toBe("'/movies/My Movie.mkv'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortKeptStreams', () => {
|
||||
test('orders by type priority (Video, Audio, Subtitle, Data), then target_index', () => {
|
||||
describe("sortKeptStreams", () => {
|
||||
test("orders by type priority (Video, Audio, Subtitle, Data), then target_index", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: 'Audio', stream_index: 1 }),
|
||||
stream({ id: 2, type: 'Video', stream_index: 0 }),
|
||||
stream({ id: 3, type: 'Audio', stream_index: 2 }),
|
||||
stream({ id: 1, type: "Audio", stream_index: 1 }),
|
||||
stream({ id: 2, type: "Video", stream_index: 0 }),
|
||||
stream({ id: 3, type: "Audio", stream_index: 2 }),
|
||||
];
|
||||
const decisions = [
|
||||
decision({ stream_id: 1, action: 'keep', target_index: 1 }),
|
||||
decision({ stream_id: 2, action: 'keep', target_index: 0 }),
|
||||
decision({ stream_id: 3, action: 'keep', target_index: 0 }),
|
||||
decision({ stream_id: 1, action: "keep", target_index: 1 }),
|
||||
decision({ stream_id: 2, action: "keep", target_index: 0 }),
|
||||
decision({ stream_id: 3, action: "keep", target_index: 0 }),
|
||||
];
|
||||
const sorted = sortKeptStreams(streams, decisions);
|
||||
expect(sorted.map(k => k.stream.id)).toEqual([2, 3, 1]);
|
||||
expect(sorted.map((k) => k.stream.id)).toEqual([2, 3, 1]);
|
||||
});
|
||||
|
||||
test('drops streams with action remove', () => {
|
||||
const streams = [stream({ id: 1, type: 'Audio', stream_index: 0 })];
|
||||
const decisions = [decision({ stream_id: 1, action: 'remove' })];
|
||||
test("drops streams with action remove", () => {
|
||||
const streams = [stream({ id: 1, type: "Audio", stream_index: 0 })];
|
||||
const decisions = [decision({ stream_id: 1, action: "remove" })];
|
||||
expect(sortKeptStreams(streams, decisions)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCommand', () => {
|
||||
test('produces ffmpeg remux with tmp-rename pattern', () => {
|
||||
describe("buildCommand", () => {
|
||||
test("produces ffmpeg remux with tmp-rename pattern", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }),
|
||||
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }),
|
||||
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }),
|
||||
];
|
||||
const decisions = [
|
||||
decision({ stream_id: 1, action: 'keep', target_index: 0 }),
|
||||
decision({ stream_id: 2, action: 'keep', target_index: 0 }),
|
||||
decision({ stream_id: 1, action: "keep", target_index: 0 }),
|
||||
decision({ stream_id: 2, action: "keep", target_index: 0 }),
|
||||
];
|
||||
const cmd = buildCommand(ITEM, streams, decisions);
|
||||
expect(cmd).toContain('ffmpeg');
|
||||
expect(cmd).toContain('-map 0:v:0');
|
||||
expect(cmd).toContain('-map 0:a:0');
|
||||
expect(cmd).toContain('-c copy');
|
||||
expect(cmd).toContain("ffmpeg");
|
||||
expect(cmd).toContain("-map 0:v:0");
|
||||
expect(cmd).toContain("-map 0:a:0");
|
||||
expect(cmd).toContain("-c copy");
|
||||
expect(cmd).toContain("'/movies/Test.tmp.mkv'");
|
||||
expect(cmd).toContain("mv '/movies/Test.tmp.mkv' '/movies/Test.mkv'");
|
||||
});
|
||||
|
||||
test('uses type-relative specifiers (0:a:N) not absolute stream_index', () => {
|
||||
test("uses type-relative specifiers (0:a:N) not absolute stream_index", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: 'Video', stream_index: 0 }),
|
||||
stream({ id: 2, type: 'Audio', stream_index: 1 }),
|
||||
stream({ id: 3, type: 'Audio', stream_index: 2 }),
|
||||
stream({ id: 1, type: "Video", stream_index: 0 }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1 }),
|
||||
stream({ id: 3, type: "Audio", stream_index: 2 }),
|
||||
];
|
||||
// Keep only the second audio; still mapped as 0:a:1
|
||||
const decisions = [
|
||||
decision({ stream_id: 1, action: 'keep', target_index: 0 }),
|
||||
decision({ stream_id: 2, action: 'remove' }),
|
||||
decision({ stream_id: 3, action: 'keep', target_index: 0 }),
|
||||
decision({ stream_id: 1, action: "keep", target_index: 0 }),
|
||||
decision({ stream_id: 2, action: "remove" }),
|
||||
decision({ stream_id: 3, action: "keep", target_index: 0 }),
|
||||
];
|
||||
const cmd = buildCommand(ITEM, streams, decisions);
|
||||
expect(cmd).toContain('-map 0:a:1');
|
||||
expect(cmd).not.toContain('-map 0:a:2');
|
||||
expect(cmd).toContain("-map 0:a:1");
|
||||
expect(cmd).not.toContain("-map 0:a:2");
|
||||
});
|
||||
|
||||
test('sets first kept audio as default, clears others', () => {
|
||||
test("sets first kept audio as default, clears others", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: 'Video', stream_index: 0 }),
|
||||
stream({ id: 2, type: 'Audio', stream_index: 1, language: 'eng' }),
|
||||
stream({ id: 3, type: 'Audio', stream_index: 2, language: 'deu' }),
|
||||
stream({ id: 1, type: "Video", stream_index: 0 }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, language: "eng" }),
|
||||
stream({ id: 3, type: "Audio", stream_index: 2, language: "deu" }),
|
||||
];
|
||||
const decisions = [
|
||||
decision({ stream_id: 1, action: 'keep', target_index: 0 }),
|
||||
decision({ stream_id: 2, action: 'keep', target_index: 0 }),
|
||||
decision({ stream_id: 3, action: 'keep', target_index: 1 }),
|
||||
decision({ stream_id: 1, action: "keep", target_index: 0 }),
|
||||
decision({ stream_id: 2, action: "keep", target_index: 0 }),
|
||||
decision({ stream_id: 3, action: "keep", target_index: 1 }),
|
||||
];
|
||||
const cmd = buildCommand(ITEM, streams, decisions);
|
||||
expect(cmd).toContain('-disposition:a:0 default');
|
||||
expect(cmd).toContain('-disposition:a:1 0');
|
||||
expect(cmd).toContain("-disposition:a:0 default");
|
||||
expect(cmd).toContain("-disposition:a:1 0");
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPipelineCommand', () => {
|
||||
test('emits subtitle extraction outputs and final remux in one pass', () => {
|
||||
describe("buildPipelineCommand", () => {
|
||||
test("emits subtitle extraction outputs and final remux in one pass", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: 'Video', stream_index: 0 }),
|
||||
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }),
|
||||
stream({ id: 3, type: 'Subtitle', stream_index: 2, codec: 'subrip', language: 'eng' }),
|
||||
stream({ id: 1, type: "Video", stream_index: 0 }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }),
|
||||
stream({ id: 3, type: "Subtitle", stream_index: 2, codec: "subrip", language: "eng" }),
|
||||
];
|
||||
const decisions = [
|
||||
decision({ stream_id: 1, action: 'keep', target_index: 0 }),
|
||||
decision({ stream_id: 2, action: 'keep', target_index: 0 }),
|
||||
decision({ stream_id: 3, action: 'remove' }),
|
||||
decision({ stream_id: 1, action: "keep", target_index: 0 }),
|
||||
decision({ stream_id: 2, action: "keep", target_index: 0 }),
|
||||
decision({ stream_id: 3, action: "remove" }),
|
||||
];
|
||||
const { command, extractedFiles } = buildPipelineCommand(ITEM, streams, decisions);
|
||||
expect(command).toContain('-map 0:s:0');
|
||||
expect(command).toContain('-c:s copy');
|
||||
expect(command).toContain("-map 0:s:0");
|
||||
expect(command).toContain("-c:s copy");
|
||||
expect(command).toContain("'/movies/Test.en.srt'");
|
||||
expect(command).toContain('-map 0:v:0');
|
||||
expect(command).toContain('-map 0:a:0');
|
||||
expect(command).toContain("-map 0:v:0");
|
||||
expect(command).toContain("-map 0:a:0");
|
||||
expect(extractedFiles).toHaveLength(1);
|
||||
expect(extractedFiles[0].path).toBe('/movies/Test.en.srt');
|
||||
expect(extractedFiles[0].path).toBe("/movies/Test.en.srt");
|
||||
});
|
||||
|
||||
test('transcodes incompatible audio with per-track codec flag', () => {
|
||||
const dtsItem = { ...ITEM, container: 'mp4', file_path: '/movies/x.mp4' };
|
||||
test("transcodes incompatible audio with per-track codec flag", () => {
|
||||
const dtsItem = { ...ITEM, container: "mp4", file_path: "/movies/x.mp4" };
|
||||
const streams = [
|
||||
stream({ id: 1, type: 'Video', stream_index: 0 }),
|
||||
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'dts', language: 'eng', channels: 6 }),
|
||||
stream({ id: 1, type: "Video", stream_index: 0 }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, codec: "dts", language: "eng", channels: 6 }),
|
||||
];
|
||||
const decisions = [
|
||||
decision({ stream_id: 1, action: 'keep', target_index: 0 }),
|
||||
decision({ stream_id: 2, action: 'keep', target_index: 0, transcode_codec: 'eac3' }),
|
||||
decision({ stream_id: 1, action: "keep", target_index: 0 }),
|
||||
decision({ stream_id: 2, action: "keep", target_index: 0, transcode_codec: "eac3" }),
|
||||
];
|
||||
const { command } = buildPipelineCommand(dtsItem, streams, decisions);
|
||||
expect(command).toContain('-c:a:0 eac3');
|
||||
expect(command).toContain('-b:a:0 640k'); // 6 channels → 640k
|
||||
expect(command).toContain("-c:a:0 eac3");
|
||||
expect(command).toContain("-b:a:0 640k"); // 6 channels → 640k
|
||||
});
|
||||
});
|
||||
|
||||
describe('predictExtractedFiles', () => {
|
||||
test('predicts sidecar paths matching extraction output', () => {
|
||||
describe("predictExtractedFiles", () => {
|
||||
test("predicts sidecar paths matching extraction output", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: 'Subtitle', stream_index: 0, codec: 'subrip', language: 'eng' }),
|
||||
stream({ id: 2, type: 'Subtitle', stream_index: 1, codec: 'subrip', language: 'deu', is_forced: 1 }),
|
||||
stream({ id: 1, type: "Subtitle", stream_index: 0, codec: "subrip", language: "eng" }),
|
||||
stream({ id: 2, type: "Subtitle", stream_index: 1, codec: "subrip", language: "deu", is_forced: 1 }),
|
||||
];
|
||||
const files = predictExtractedFiles(ITEM, streams);
|
||||
expect(files).toHaveLength(2);
|
||||
expect(files[0].file_path).toBe('/movies/Test.en.srt');
|
||||
expect(files[1].file_path).toBe('/movies/Test.de.forced.srt');
|
||||
expect(files[0].file_path).toBe("/movies/Test.en.srt");
|
||||
expect(files[1].file_path).toBe("/movies/Test.de.forced.srt");
|
||||
expect(files[1].is_forced).toBe(true);
|
||||
});
|
||||
|
||||
test('deduplicates paths with a numeric suffix', () => {
|
||||
test("deduplicates paths with a numeric suffix", () => {
|
||||
const streams = [
|
||||
stream({ id: 1, type: 'Subtitle', stream_index: 0, codec: 'subrip', language: 'eng' }),
|
||||
stream({ id: 2, type: 'Subtitle', stream_index: 1, codec: 'subrip', language: 'eng' }),
|
||||
stream({ id: 1, type: "Subtitle", stream_index: 0, codec: "subrip", language: "eng" }),
|
||||
stream({ id: 2, type: "Subtitle", stream_index: 1, codec: "subrip", language: "eng" }),
|
||||
];
|
||||
const files = predictExtractedFiles(ITEM, streams);
|
||||
expect(files[0].file_path).toBe('/movies/Test.en.srt');
|
||||
expect(files[1].file_path).toBe('/movies/Test.en.2.srt');
|
||||
expect(files[0].file_path).toBe("/movies/Test.en.srt");
|
||||
expect(files[1].file_path).toBe("/movies/Test.en.2.srt");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { MediaItem, MediaStream, PlanResult } from '../types';
|
||||
import { normalizeLanguage } from './jellyfin';
|
||||
import { transcodeTarget, computeAppleCompat } from './apple-compat';
|
||||
import type { MediaItem, MediaStream, PlanResult } from "../types";
|
||||
import { computeAppleCompat, transcodeTarget } from "./apple-compat";
|
||||
import { normalizeLanguage } from "./jellyfin";
|
||||
|
||||
export interface AnalyzerConfig {
|
||||
subtitleLanguages: string[];
|
||||
@@ -17,77 +17,73 @@ export interface AnalyzerConfig {
|
||||
* at all.
|
||||
*/
|
||||
export function analyzeItem(
|
||||
item: Pick<MediaItem, 'original_language' | 'needs_review' | 'container'>,
|
||||
item: Pick<MediaItem, "original_language" | "needs_review" | "container">,
|
||||
streams: MediaStream[],
|
||||
config: AnalyzerConfig
|
||||
config: AnalyzerConfig,
|
||||
): PlanResult {
|
||||
const origLang = item.original_language ? normalizeLanguage(item.original_language) : null;
|
||||
const notes: string[] = [];
|
||||
|
||||
const decisions: PlanResult['decisions'] = streams.map((s) => {
|
||||
const decisions: PlanResult["decisions"] = streams.map((s) => {
|
||||
const action = decideAction(s, origLang, config.audioLanguages);
|
||||
return { stream_id: s.id, action, target_index: null, transcode_codec: null };
|
||||
});
|
||||
|
||||
const anyAudioRemoved = streams.some((s, i) => s.type === 'Audio' && decisions[i].action === 'remove');
|
||||
const anyAudioRemoved = streams.some((s, i) => s.type === "Audio" && decisions[i].action === "remove");
|
||||
|
||||
assignTargetOrder(streams, decisions, origLang, config.audioLanguages);
|
||||
|
||||
const audioOrderChanged = checkAudioOrderChanged(streams, decisions);
|
||||
|
||||
for (const d of decisions) {
|
||||
if (d.action !== 'keep') continue;
|
||||
const stream = streams.find(s => s.id === d.stream_id);
|
||||
if (stream && stream.type === 'Audio') {
|
||||
d.transcode_codec = transcodeTarget(stream.codec ?? '', stream.title, item.container);
|
||||
if (d.action !== "keep") continue;
|
||||
const stream = streams.find((s) => s.id === d.stream_id);
|
||||
if (stream && stream.type === "Audio") {
|
||||
d.transcode_codec = transcodeTarget(stream.codec ?? "", stream.title, item.container);
|
||||
}
|
||||
}
|
||||
|
||||
const keptAudioCodecs = decisions
|
||||
.filter(d => d.action === 'keep')
|
||||
.map(d => streams.find(s => s.id === d.stream_id))
|
||||
.filter((s): s is MediaStream => !!s && s.type === 'Audio')
|
||||
.map(s => s.codec ?? '');
|
||||
.filter((d) => d.action === "keep")
|
||||
.map((d) => streams.find((s) => s.id === d.stream_id))
|
||||
.filter((s): s is MediaStream => !!s && s.type === "Audio")
|
||||
.map((s) => s.codec ?? "");
|
||||
|
||||
const needsTranscode = decisions.some(d => d.transcode_codec != null);
|
||||
const needsTranscode = decisions.some((d) => d.transcode_codec != null);
|
||||
const apple_compat = computeAppleCompat(keptAudioCodecs, item.container);
|
||||
const job_type = needsTranscode ? 'transcode' as const : 'copy' as const;
|
||||
const job_type = needsTranscode ? ("transcode" as const) : ("copy" as const);
|
||||
|
||||
const hasSubs = streams.some((s) => s.type === 'Subtitle');
|
||||
const hasSubs = streams.some((s) => s.type === "Subtitle");
|
||||
const is_noop = !anyAudioRemoved && !audioOrderChanged && !hasSubs && !needsTranscode;
|
||||
|
||||
if (!origLang && item.needs_review) {
|
||||
notes.push('Original language unknown — audio tracks not filtered; manual review required');
|
||||
notes.push("Original language unknown — audio tracks not filtered; manual review required");
|
||||
}
|
||||
|
||||
return { is_noop, has_subs: hasSubs, confidence: 'low', apple_compat, job_type, decisions, notes };
|
||||
return { is_noop, has_subs: hasSubs, confidence: "low", apple_compat, job_type, decisions, notes };
|
||||
}
|
||||
|
||||
function decideAction(
|
||||
stream: MediaStream,
|
||||
origLang: string | null,
|
||||
audioLanguages: string[],
|
||||
): 'keep' | 'remove' {
|
||||
function decideAction(stream: MediaStream, origLang: string | null, audioLanguages: string[]): "keep" | "remove" {
|
||||
switch (stream.type) {
|
||||
case 'Video':
|
||||
case 'Data':
|
||||
case 'EmbeddedImage':
|
||||
return 'keep';
|
||||
case "Video":
|
||||
case "Data":
|
||||
case "EmbeddedImage":
|
||||
return "keep";
|
||||
|
||||
case 'Audio': {
|
||||
if (!origLang) return 'keep';
|
||||
if (!stream.language) return 'keep';
|
||||
case "Audio": {
|
||||
if (!origLang) return "keep";
|
||||
if (!stream.language) return "keep";
|
||||
const normalized = normalizeLanguage(stream.language);
|
||||
if (normalized === origLang) return 'keep';
|
||||
if (audioLanguages.includes(normalized)) return 'keep';
|
||||
return 'remove';
|
||||
if (normalized === origLang) return "keep";
|
||||
if (audioLanguages.includes(normalized)) return "keep";
|
||||
return "remove";
|
||||
}
|
||||
|
||||
case 'Subtitle':
|
||||
return 'remove';
|
||||
case "Subtitle":
|
||||
return "remove";
|
||||
|
||||
default:
|
||||
return 'keep';
|
||||
return "keep";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,19 +95,19 @@ function decideAction(
|
||||
*/
|
||||
export function assignTargetOrder(
|
||||
allStreams: MediaStream[],
|
||||
decisions: PlanResult['decisions'],
|
||||
decisions: PlanResult["decisions"],
|
||||
origLang: string | null,
|
||||
audioLanguages: string[],
|
||||
): void {
|
||||
const keptByType = new Map<string, MediaStream[]>();
|
||||
for (const s of allStreams) {
|
||||
const dec = decisions.find(d => d.stream_id === s.id);
|
||||
if (dec?.action !== 'keep') continue;
|
||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||
if (dec?.action !== "keep") continue;
|
||||
if (!keptByType.has(s.type)) keptByType.set(s.type, []);
|
||||
keptByType.get(s.type)!.push(s);
|
||||
}
|
||||
|
||||
const audio = keptByType.get('Audio');
|
||||
const audio = keptByType.get("Audio");
|
||||
if (audio) {
|
||||
audio.sort((a, b) => {
|
||||
const aRank = langRank(a.language, origLang, audioLanguages);
|
||||
@@ -123,7 +119,7 @@ export function assignTargetOrder(
|
||||
|
||||
for (const [, streams] of keptByType) {
|
||||
streams.forEach((s, idx) => {
|
||||
const dec = decisions.find(d => d.stream_id === s.id);
|
||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||
if (dec) dec.target_index = idx;
|
||||
});
|
||||
}
|
||||
@@ -144,16 +140,13 @@ function langRank(lang: string | null, origLang: string | null, audioLanguages:
|
||||
* original order in the input. Compares original stream_index order
|
||||
* against target_index order.
|
||||
*/
|
||||
function checkAudioOrderChanged(
|
||||
streams: MediaStream[],
|
||||
decisions: PlanResult['decisions']
|
||||
): boolean {
|
||||
function checkAudioOrderChanged(streams: MediaStream[], decisions: PlanResult["decisions"]): boolean {
|
||||
const keptAudio = streams
|
||||
.filter(s => s.type === 'Audio' && decisions.find(d => d.stream_id === s.id)?.action === 'keep')
|
||||
.filter((s) => s.type === "Audio" && decisions.find((d) => d.stream_id === s.id)?.action === "keep")
|
||||
.sort((a, b) => a.stream_index - b.stream_index);
|
||||
|
||||
for (let i = 0; i < keptAudio.length; i++) {
|
||||
const dec = decisions.find(d => d.stream_id === keptAudio[i].id);
|
||||
const dec = decisions.find((d) => d.stream_id === keptAudio[i].id);
|
||||
if (dec?.target_index !== i) return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -3,64 +3,67 @@
|
||||
// Everything else (DTS family, TrueHD family) needs transcoding.
|
||||
|
||||
const APPLE_COMPATIBLE_AUDIO = new Set([
|
||||
'aac', 'ac3', 'eac3', 'alac', 'flac', 'mp3',
|
||||
'pcm_s16le', 'pcm_s24le', 'pcm_s32le', 'pcm_f32le',
|
||||
'pcm_s16be', 'pcm_s24be', 'pcm_s32be', 'pcm_f64le',
|
||||
'opus',
|
||||
"aac",
|
||||
"ac3",
|
||||
"eac3",
|
||||
"alac",
|
||||
"flac",
|
||||
"mp3",
|
||||
"pcm_s16le",
|
||||
"pcm_s24le",
|
||||
"pcm_s32le",
|
||||
"pcm_f32le",
|
||||
"pcm_s16be",
|
||||
"pcm_s24be",
|
||||
"pcm_s32be",
|
||||
"pcm_f64le",
|
||||
"opus",
|
||||
]);
|
||||
|
||||
// Codec strings Jellyfin may report for DTS variants
|
||||
const DTS_CODECS = new Set([
|
||||
'dts', 'dca',
|
||||
]);
|
||||
const DTS_CODECS = new Set(["dts", "dca"]);
|
||||
|
||||
const TRUEHD_CODECS = new Set([
|
||||
'truehd',
|
||||
]);
|
||||
const TRUEHD_CODECS = new Set(["truehd"]);
|
||||
|
||||
export function isAppleCompatible(codec: string): boolean {
|
||||
return APPLE_COMPATIBLE_AUDIO.has(codec.toLowerCase());
|
||||
}
|
||||
|
||||
/** Maps (codec, profile, container) → target codec for transcoding. */
|
||||
export function transcodeTarget(
|
||||
codec: string,
|
||||
profile: string | null,
|
||||
container: string | null,
|
||||
): string | null {
|
||||
export function transcodeTarget(codec: string, profile: string | null, container: string | null): string | null {
|
||||
const c = codec.toLowerCase();
|
||||
const isMkv = !container || container.toLowerCase() === 'mkv' || container.toLowerCase() === 'matroska';
|
||||
const isMkv = !container || container.toLowerCase() === "mkv" || container.toLowerCase() === "matroska";
|
||||
|
||||
if (isAppleCompatible(c)) return null; // no transcode needed
|
||||
|
||||
// DTS-HD MA and DTS:X are lossless → FLAC in MKV, EAC3 in MP4
|
||||
if (DTS_CODECS.has(c)) {
|
||||
const p = (profile ?? '').toLowerCase();
|
||||
const isLossless = p.includes('ma') || p.includes('hd ma') || p.includes('x');
|
||||
if (isLossless) return isMkv ? 'flac' : 'eac3';
|
||||
const p = (profile ?? "").toLowerCase();
|
||||
const isLossless = p.includes("ma") || p.includes("hd ma") || p.includes("x");
|
||||
if (isLossless) return isMkv ? "flac" : "eac3";
|
||||
// Lossy DTS variants → EAC3
|
||||
return 'eac3';
|
||||
return "eac3";
|
||||
}
|
||||
|
||||
// TrueHD (including Atmos) → FLAC in MKV, EAC3 in MP4
|
||||
if (TRUEHD_CODECS.has(c)) {
|
||||
return isMkv ? 'flac' : 'eac3';
|
||||
return isMkv ? "flac" : "eac3";
|
||||
}
|
||||
|
||||
// Any other incompatible codec → EAC3 as safe fallback
|
||||
return 'eac3';
|
||||
return "eac3";
|
||||
}
|
||||
|
||||
/** Determine overall Apple compatibility for a set of kept audio streams. */
|
||||
export function computeAppleCompat(
|
||||
keptAudioCodecs: string[],
|
||||
container: string | null,
|
||||
): 'direct_play' | 'remux' | 'audio_transcode' {
|
||||
const hasIncompatible = keptAudioCodecs.some(c => !isAppleCompatible(c));
|
||||
if (hasIncompatible) return 'audio_transcode';
|
||||
): "direct_play" | "remux" | "audio_transcode" {
|
||||
const hasIncompatible = keptAudioCodecs.some((c) => !isAppleCompatible(c));
|
||||
if (hasIncompatible) return "audio_transcode";
|
||||
|
||||
const isMkv = !container || container.toLowerCase() === 'mkv' || container.toLowerCase() === 'matroska';
|
||||
if (isMkv) return 'remux';
|
||||
const isMkv = !container || container.toLowerCase() === "mkv" || container.toLowerCase() === "matroska";
|
||||
if (isMkv) return "remux";
|
||||
|
||||
return 'direct_play';
|
||||
return "direct_play";
|
||||
}
|
||||
|
||||
@@ -1,44 +1,83 @@
|
||||
import type { MediaItem, MediaStream, StreamDecision } from '../types';
|
||||
import { normalizeLanguage } from './jellyfin';
|
||||
import type { MediaItem, MediaStream, StreamDecision } from "../types";
|
||||
import { normalizeLanguage } from "./jellyfin";
|
||||
|
||||
// ─── Subtitle extraction helpers ──────────────────────────────────────────────
|
||||
|
||||
/** ISO 639-2/B → ISO 639-1 two-letter codes for subtitle filenames. */
|
||||
const ISO639_1: Record<string, string> = {
|
||||
eng: 'en', deu: 'de', spa: 'es', fra: 'fr', ita: 'it',
|
||||
por: 'pt', jpn: 'ja', kor: 'ko', zho: 'zh', ara: 'ar',
|
||||
rus: 'ru', nld: 'nl', swe: 'sv', nor: 'no', dan: 'da',
|
||||
fin: 'fi', pol: 'pl', tur: 'tr', tha: 'th', hin: 'hi',
|
||||
hun: 'hu', ces: 'cs', ron: 'ro', ell: 'el', heb: 'he',
|
||||
fas: 'fa', ukr: 'uk', ind: 'id', cat: 'ca', nob: 'nb',
|
||||
nno: 'nn', isl: 'is', hrv: 'hr', slk: 'sk', bul: 'bg',
|
||||
srp: 'sr', slv: 'sl', lav: 'lv', lit: 'lt', est: 'et',
|
||||
eng: "en",
|
||||
deu: "de",
|
||||
spa: "es",
|
||||
fra: "fr",
|
||||
ita: "it",
|
||||
por: "pt",
|
||||
jpn: "ja",
|
||||
kor: "ko",
|
||||
zho: "zh",
|
||||
ara: "ar",
|
||||
rus: "ru",
|
||||
nld: "nl",
|
||||
swe: "sv",
|
||||
nor: "no",
|
||||
dan: "da",
|
||||
fin: "fi",
|
||||
pol: "pl",
|
||||
tur: "tr",
|
||||
tha: "th",
|
||||
hin: "hi",
|
||||
hun: "hu",
|
||||
ces: "cs",
|
||||
ron: "ro",
|
||||
ell: "el",
|
||||
heb: "he",
|
||||
fas: "fa",
|
||||
ukr: "uk",
|
||||
ind: "id",
|
||||
cat: "ca",
|
||||
nob: "nb",
|
||||
nno: "nn",
|
||||
isl: "is",
|
||||
hrv: "hr",
|
||||
slk: "sk",
|
||||
bul: "bg",
|
||||
srp: "sr",
|
||||
slv: "sl",
|
||||
lav: "lv",
|
||||
lit: "lt",
|
||||
est: "et",
|
||||
};
|
||||
|
||||
/** Subtitle codec → external file extension. */
|
||||
const SUBTITLE_EXT: Record<string, string> = {
|
||||
subrip: 'srt', srt: 'srt', ass: 'ass', ssa: 'ssa',
|
||||
webvtt: 'vtt', vtt: 'vtt',
|
||||
hdmv_pgs_subtitle: 'sup', pgssub: 'sup',
|
||||
dvd_subtitle: 'sub', dvbsub: 'sub',
|
||||
mov_text: 'srt', text: 'srt',
|
||||
subrip: "srt",
|
||||
srt: "srt",
|
||||
ass: "ass",
|
||||
ssa: "ssa",
|
||||
webvtt: "vtt",
|
||||
vtt: "vtt",
|
||||
hdmv_pgs_subtitle: "sup",
|
||||
pgssub: "sup",
|
||||
dvd_subtitle: "sub",
|
||||
dvbsub: "sub",
|
||||
mov_text: "srt",
|
||||
text: "srt",
|
||||
};
|
||||
|
||||
function subtitleLang2(lang: string | null): string {
|
||||
if (!lang) return 'und';
|
||||
if (!lang) return "und";
|
||||
const n = normalizeLanguage(lang);
|
||||
return ISO639_1[n] ?? n;
|
||||
}
|
||||
|
||||
/** Returns the ffmpeg codec name to use when extracting this subtitle stream. */
|
||||
function subtitleCodecArg(codec: string | null): string {
|
||||
if (!codec) return 'copy';
|
||||
return codec.toLowerCase() === 'mov_text' ? 'subrip' : 'copy';
|
||||
if (!codec) return "copy";
|
||||
return codec.toLowerCase() === "mov_text" ? "subrip" : "copy";
|
||||
}
|
||||
|
||||
function subtitleExtForCodec(codec: string | null): string {
|
||||
if (!codec) return 'srt';
|
||||
return SUBTITLE_EXT[codec.toLowerCase()] ?? 'srt';
|
||||
if (!codec) return "srt";
|
||||
return SUBTITLE_EXT[codec.toLowerCase()] ?? "srt";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,19 +99,14 @@ interface ExtractionEntry {
|
||||
}
|
||||
|
||||
/** Compute extraction metadata for all subtitle streams. Shared by buildExtractionOutputs and predictExtractedFiles. */
|
||||
function computeExtractionEntries(
|
||||
allStreams: MediaStream[],
|
||||
basePath: string
|
||||
): ExtractionEntry[] {
|
||||
function computeExtractionEntries(allStreams: MediaStream[], basePath: string): ExtractionEntry[] {
|
||||
const subTypeIdx = new Map<number, number>();
|
||||
let subCount = 0;
|
||||
for (const s of [...allStreams].sort((a, b) => a.stream_index - b.stream_index)) {
|
||||
if (s.type === 'Subtitle') subTypeIdx.set(s.id, subCount++);
|
||||
if (s.type === "Subtitle") subTypeIdx.set(s.id, subCount++);
|
||||
}
|
||||
|
||||
const allSubs = allStreams
|
||||
.filter((s) => s.type === 'Subtitle')
|
||||
.sort((a, b) => a.stream_index - b.stream_index);
|
||||
const allSubs = allStreams.filter((s) => s.type === "Subtitle").sort((a, b) => a.stream_index - b.stream_index);
|
||||
|
||||
if (allSubs.length === 0) return [];
|
||||
|
||||
@@ -86,13 +120,13 @@ function computeExtractionEntries(
|
||||
const codecArg = subtitleCodecArg(s.codec);
|
||||
|
||||
const nameParts = [langCode];
|
||||
if (s.is_forced) nameParts.push('forced');
|
||||
if (s.is_hearing_impaired) nameParts.push('hi');
|
||||
if (s.is_forced) nameParts.push("forced");
|
||||
if (s.is_hearing_impaired) nameParts.push("hi");
|
||||
|
||||
let outPath = `${basePath}.${nameParts.join('.')}.${ext}`;
|
||||
let outPath = `${basePath}.${nameParts.join(".")}.${ext}`;
|
||||
let counter = 2;
|
||||
while (usedNames.has(outPath)) {
|
||||
outPath = `${basePath}.${nameParts.join('.')}.${counter}.${ext}`;
|
||||
outPath = `${basePath}.${nameParts.join(".")}.${counter}.${ext}`;
|
||||
counter++;
|
||||
}
|
||||
usedNames.add(outPath);
|
||||
@@ -103,10 +137,7 @@ function computeExtractionEntries(
|
||||
return entries;
|
||||
}
|
||||
|
||||
function buildExtractionOutputs(
|
||||
allStreams: MediaStream[],
|
||||
basePath: string
|
||||
): string[] {
|
||||
function buildExtractionOutputs(allStreams: MediaStream[], basePath: string): string[] {
|
||||
const entries = computeExtractionEntries(allStreams, basePath);
|
||||
const args: string[] = [];
|
||||
for (const e of entries) {
|
||||
@@ -121,9 +152,15 @@ function buildExtractionOutputs(
|
||||
*/
|
||||
export function predictExtractedFiles(
|
||||
item: MediaItem,
|
||||
streams: MediaStream[]
|
||||
): Array<{ file_path: string; language: string | null; codec: string | null; is_forced: boolean; is_hearing_impaired: boolean }> {
|
||||
const basePath = item.file_path.replace(/\.[^.]+$/, '');
|
||||
streams: MediaStream[],
|
||||
): Array<{
|
||||
file_path: string;
|
||||
language: string | null;
|
||||
codec: string | null;
|
||||
is_forced: boolean;
|
||||
is_hearing_impaired: boolean;
|
||||
}> {
|
||||
const basePath = item.file_path.replace(/\.[^.]+$/, "");
|
||||
const entries = computeExtractionEntries(streams, basePath);
|
||||
return entries.map((e) => ({
|
||||
file_path: e.outPath,
|
||||
@@ -137,21 +174,50 @@ export function predictExtractedFiles(
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const LANG_NAMES: Record<string, string> = {
|
||||
eng: 'English', deu: 'German', spa: 'Spanish', fra: 'French',
|
||||
ita: 'Italian', por: 'Portuguese', jpn: 'Japanese', kor: 'Korean',
|
||||
zho: 'Chinese', ara: 'Arabic', rus: 'Russian', nld: 'Dutch',
|
||||
swe: 'Swedish', nor: 'Norwegian', dan: 'Danish', fin: 'Finnish',
|
||||
pol: 'Polish', tur: 'Turkish', tha: 'Thai', hin: 'Hindi',
|
||||
hun: 'Hungarian', ces: 'Czech', ron: 'Romanian', ell: 'Greek',
|
||||
heb: 'Hebrew', fas: 'Persian', ukr: 'Ukrainian', ind: 'Indonesian',
|
||||
cat: 'Catalan', nob: 'Norwegian Bokmål', nno: 'Norwegian Nynorsk',
|
||||
isl: 'Icelandic', slk: 'Slovak', hrv: 'Croatian', bul: 'Bulgarian',
|
||||
srp: 'Serbian', slv: 'Slovenian', lav: 'Latvian', lit: 'Lithuanian',
|
||||
est: 'Estonian',
|
||||
eng: "English",
|
||||
deu: "German",
|
||||
spa: "Spanish",
|
||||
fra: "French",
|
||||
ita: "Italian",
|
||||
por: "Portuguese",
|
||||
jpn: "Japanese",
|
||||
kor: "Korean",
|
||||
zho: "Chinese",
|
||||
ara: "Arabic",
|
||||
rus: "Russian",
|
||||
nld: "Dutch",
|
||||
swe: "Swedish",
|
||||
nor: "Norwegian",
|
||||
dan: "Danish",
|
||||
fin: "Finnish",
|
||||
pol: "Polish",
|
||||
tur: "Turkish",
|
||||
tha: "Thai",
|
||||
hin: "Hindi",
|
||||
hun: "Hungarian",
|
||||
ces: "Czech",
|
||||
ron: "Romanian",
|
||||
ell: "Greek",
|
||||
heb: "Hebrew",
|
||||
fas: "Persian",
|
||||
ukr: "Ukrainian",
|
||||
ind: "Indonesian",
|
||||
cat: "Catalan",
|
||||
nob: "Norwegian Bokmål",
|
||||
nno: "Norwegian Nynorsk",
|
||||
isl: "Icelandic",
|
||||
slk: "Slovak",
|
||||
hrv: "Croatian",
|
||||
bul: "Bulgarian",
|
||||
srp: "Serbian",
|
||||
slv: "Slovenian",
|
||||
lav: "Latvian",
|
||||
lit: "Lithuanian",
|
||||
est: "Estonian",
|
||||
};
|
||||
|
||||
function trackTitle(stream: MediaStream): string | null {
|
||||
if (stream.type === 'Subtitle') {
|
||||
if (stream.type === "Subtitle") {
|
||||
// Subtitles always get a clean language-based title so Jellyfin displays
|
||||
// "German", "English (Forced)", etc. regardless of the original file title.
|
||||
// The review UI shows a ⚠ badge when the original title looks like a
|
||||
@@ -171,7 +237,7 @@ function trackTitle(stream: MediaStream): string | null {
|
||||
return LANG_NAMES[lang] ?? lang.toUpperCase();
|
||||
}
|
||||
|
||||
const TYPE_SPEC: Record<string, string> = { Video: 'v', Audio: 'a', Subtitle: 's' };
|
||||
const TYPE_SPEC: Record<string, string> = { Video: "v", Audio: "a", Subtitle: "s" };
|
||||
|
||||
/**
|
||||
* Build -map flags using type-relative specifiers (0:v:N, 0:a:N, 0:s:N).
|
||||
@@ -181,10 +247,7 @@ const TYPE_SPEC: Record<string, string> = { Video: 'v', Audio: 'a', Subtitle: 's
|
||||
* as attachments). Using the stream's position within its own type group
|
||||
* matches ffmpeg's 0:a:N convention exactly and avoids silent mismatches.
|
||||
*/
|
||||
function buildMaps(
|
||||
allStreams: MediaStream[],
|
||||
kept: { stream: MediaStream; dec: StreamDecision }[]
|
||||
): string[] {
|
||||
function buildMaps(allStreams: MediaStream[], kept: { stream: MediaStream; dec: StreamDecision }[]): string[] {
|
||||
// Map each stream id → its 0-based position among streams of the same type,
|
||||
// sorted by stream_index (the order ffmpeg sees them in the input).
|
||||
const typePos = new Map<number, number>();
|
||||
@@ -206,15 +269,13 @@ function buildMaps(
|
||||
* - Marks the first kept audio stream as default, clears all others.
|
||||
* - Sets harmonized language-name titles on all kept audio streams.
|
||||
*/
|
||||
function buildStreamFlags(
|
||||
kept: { stream: MediaStream; dec: StreamDecision }[]
|
||||
): string[] {
|
||||
const audioKept = kept.filter((k) => k.stream.type === 'Audio');
|
||||
function buildStreamFlags(kept: { stream: MediaStream; dec: StreamDecision }[]): string[] {
|
||||
const audioKept = kept.filter((k) => k.stream.type === "Audio");
|
||||
const args: string[] = [];
|
||||
|
||||
// Disposition: first audio = default, rest = clear
|
||||
audioKept.forEach((_, i) => {
|
||||
args.push(`-disposition:a:${i}`, i === 0 ? 'default' : '0');
|
||||
args.push(`-disposition:a:${i}`, i === 0 ? "default" : "0");
|
||||
});
|
||||
|
||||
// Titles for audio streams (custom_title overrides generated title)
|
||||
@@ -236,12 +297,12 @@ const TYPE_ORDER: Record<string, number> = { Video: 0, Audio: 1, Subtitle: 2, Da
|
||||
*/
|
||||
export function sortKeptStreams(
|
||||
streams: MediaStream[],
|
||||
decisions: StreamDecision[]
|
||||
decisions: StreamDecision[],
|
||||
): { stream: MediaStream; dec: StreamDecision }[] {
|
||||
const kept: { stream: MediaStream; dec: StreamDecision }[] = [];
|
||||
for (const s of streams) {
|
||||
const dec = decisions.find(d => d.stream_id === s.id);
|
||||
if (dec?.action === 'keep') kept.push({ stream: s, dec });
|
||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||
if (dec?.action === "keep") kept.push({ stream: s, dec });
|
||||
}
|
||||
kept.sort((a, b) => {
|
||||
const ta = TYPE_ORDER[a.stream.type] ?? 9;
|
||||
@@ -258,47 +319,42 @@ export function sortKeptStreams(
|
||||
*
|
||||
* Returns null if all streams are kept and ordering is unchanged (noop).
|
||||
*/
|
||||
export function buildCommand(
|
||||
item: MediaItem,
|
||||
streams: MediaStream[],
|
||||
decisions: StreamDecision[]
|
||||
): string {
|
||||
export function buildCommand(item: MediaItem, streams: MediaStream[], decisions: StreamDecision[]): string {
|
||||
const kept = sortKeptStreams(streams, decisions);
|
||||
|
||||
const inputPath = item.file_path;
|
||||
const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv';
|
||||
const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? "mkv";
|
||||
const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
|
||||
|
||||
const maps = buildMaps(streams, kept);
|
||||
const streamFlags = buildStreamFlags(kept);
|
||||
|
||||
const parts: string[] = [
|
||||
'ffmpeg',
|
||||
'-y',
|
||||
'-i', shellQuote(inputPath),
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
shellQuote(inputPath),
|
||||
...maps,
|
||||
...streamFlags,
|
||||
'-c copy',
|
||||
"-c copy",
|
||||
shellQuote(tmpPath),
|
||||
'&&',
|
||||
'mv', shellQuote(tmpPath), shellQuote(inputPath),
|
||||
"&&",
|
||||
"mv",
|
||||
shellQuote(tmpPath),
|
||||
shellQuote(inputPath),
|
||||
];
|
||||
|
||||
return parts.join(' ');
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a command that also changes the container to MKV.
|
||||
* Used when MP4 container can't hold certain subtitle codecs.
|
||||
*/
|
||||
export function buildMkvConvertCommand(
|
||||
item: MediaItem,
|
||||
streams: MediaStream[],
|
||||
decisions: StreamDecision[]
|
||||
): string {
|
||||
export function buildMkvConvertCommand(item: MediaItem, streams: MediaStream[], decisions: StreamDecision[]): string {
|
||||
const inputPath = item.file_path;
|
||||
const outputPath = inputPath.replace(/\.[^.]+$/, '.mkv');
|
||||
const tmpPath = inputPath.replace(/\.[^.]+$/, '.tmp.mkv');
|
||||
const outputPath = inputPath.replace(/\.[^.]+$/, ".mkv");
|
||||
const tmpPath = inputPath.replace(/\.[^.]+$/, ".tmp.mkv");
|
||||
|
||||
const kept = sortKeptStreams(streams, decisions);
|
||||
|
||||
@@ -306,16 +362,20 @@ export function buildMkvConvertCommand(
|
||||
const streamFlags = buildStreamFlags(kept);
|
||||
|
||||
return [
|
||||
'ffmpeg', '-y',
|
||||
'-i', shellQuote(inputPath),
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
shellQuote(inputPath),
|
||||
...maps,
|
||||
...streamFlags,
|
||||
'-c copy',
|
||||
'-f matroska',
|
||||
"-c copy",
|
||||
"-f matroska",
|
||||
shellQuote(tmpPath),
|
||||
'&&',
|
||||
'mv', shellQuote(tmpPath), shellQuote(outputPath),
|
||||
].join(' ');
|
||||
"&&",
|
||||
"mv",
|
||||
shellQuote(tmpPath),
|
||||
shellQuote(outputPath),
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -326,37 +386,38 @@ export function buildMkvConvertCommand(
|
||||
* 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(/\.[^.]+$/, '');
|
||||
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 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'];
|
||||
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),
|
||||
"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',
|
||||
"-c copy",
|
||||
shellQuote(tmpPath),
|
||||
'&&',
|
||||
'mv', shellQuote(tmpPath), shellQuote(inputPath),
|
||||
"&&",
|
||||
"mv",
|
||||
shellQuote(tmpPath),
|
||||
shellQuote(inputPath),
|
||||
];
|
||||
|
||||
return parts.join(' ');
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -368,12 +429,21 @@ export function buildExtractOnlyCommand(
|
||||
export function buildPipelineCommand(
|
||||
item: MediaItem,
|
||||
streams: MediaStream[],
|
||||
decisions: (StreamDecision & { stream?: MediaStream })[]
|
||||
): { command: string; extractedFiles: Array<{ path: string; language: string | null; codec: string | null; is_forced: number; is_hearing_impaired: number }> } {
|
||||
decisions: (StreamDecision & { stream?: MediaStream })[],
|
||||
): {
|
||||
command: string;
|
||||
extractedFiles: Array<{
|
||||
path: string;
|
||||
language: string | null;
|
||||
codec: string | null;
|
||||
is_forced: number;
|
||||
is_hearing_impaired: number;
|
||||
}>;
|
||||
} {
|
||||
const inputPath = item.file_path;
|
||||
const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv';
|
||||
const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? "mkv";
|
||||
const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
|
||||
const basePath = inputPath.replace(/\.[^.]+$/, '');
|
||||
const basePath = inputPath.replace(/\.[^.]+$/, "");
|
||||
|
||||
// --- Subtitle extraction outputs ---
|
||||
const extractionEntries = computeExtractionEntries(streams, basePath);
|
||||
@@ -384,21 +454,21 @@ export function buildPipelineCommand(
|
||||
|
||||
// --- Kept streams for remuxed output ---
|
||||
const kept = sortKeptStreams(streams, decisions as StreamDecision[]);
|
||||
const enriched = kept.map(k => ({ ...k.dec, stream: k.stream }));
|
||||
const enriched = kept.map((k) => ({ ...k.dec, stream: k.stream }));
|
||||
|
||||
// Build -map flags
|
||||
const maps = buildMaps(streams, kept);
|
||||
|
||||
// Build per-stream codec flags
|
||||
const codecFlags: string[] = ['-c:v copy'];
|
||||
const codecFlags: string[] = ["-c:v copy"];
|
||||
let audioIdx = 0;
|
||||
for (const d of enriched) {
|
||||
if (d.stream.type === 'Audio') {
|
||||
if (d.stream.type === "Audio") {
|
||||
if (d.transcode_codec) {
|
||||
codecFlags.push(`-c:a:${audioIdx} ${d.transcode_codec}`);
|
||||
// For EAC3, set a reasonable bitrate based on channel count
|
||||
if (d.transcode_codec === 'eac3') {
|
||||
const bitrate = (d.stream.channels ?? 2) >= 6 ? '640k' : '256k';
|
||||
if (d.transcode_codec === "eac3") {
|
||||
const bitrate = (d.stream.channels ?? 2) >= 6 ? "640k" : "256k";
|
||||
codecFlags.push(`-b:a:${audioIdx} ${bitrate}`);
|
||||
}
|
||||
} else {
|
||||
@@ -409,17 +479,14 @@ export function buildPipelineCommand(
|
||||
}
|
||||
|
||||
// If no audio transcoding, simplify to -c copy (covers video + audio)
|
||||
const hasTranscode = enriched.some(d => d.transcode_codec);
|
||||
const finalCodecFlags = hasTranscode ? codecFlags : ['-c copy'];
|
||||
const hasTranscode = enriched.some((d) => d.transcode_codec);
|
||||
const finalCodecFlags = hasTranscode ? codecFlags : ["-c copy"];
|
||||
|
||||
// Disposition + metadata flags for audio
|
||||
const streamFlags = buildStreamFlags(kept);
|
||||
|
||||
// Assemble command
|
||||
const parts: string[] = [
|
||||
'ffmpeg', '-y',
|
||||
'-i', shellQuote(inputPath),
|
||||
];
|
||||
const parts: string[] = ["ffmpeg", "-y", "-i", shellQuote(inputPath)];
|
||||
|
||||
// Subtitle extraction outputs first
|
||||
parts.push(...subOutputArgs);
|
||||
@@ -436,12 +503,11 @@ export function buildPipelineCommand(
|
||||
// Output file
|
||||
parts.push(shellQuote(tmpPath));
|
||||
|
||||
const command = parts.join(' ')
|
||||
+ ` && mv ${shellQuote(tmpPath)} ${shellQuote(inputPath)}`;
|
||||
const command = `${parts.join(" ")} && mv ${shellQuote(tmpPath)} ${shellQuote(inputPath)}`;
|
||||
|
||||
return {
|
||||
command,
|
||||
extractedFiles: extractionEntries.map(e => ({
|
||||
extractedFiles: extractionEntries.map((e) => ({
|
||||
path: e.outPath,
|
||||
language: e.stream.language,
|
||||
codec: e.stream.codec,
|
||||
@@ -459,13 +525,13 @@ export function shellQuote(s: string): string {
|
||||
/** Returns a human-readable summary of what will change. */
|
||||
export function summarizeChanges(
|
||||
streams: MediaStream[],
|
||||
decisions: StreamDecision[]
|
||||
decisions: StreamDecision[],
|
||||
): { removed: MediaStream[]; kept: MediaStream[] } {
|
||||
const removed: MediaStream[] = [];
|
||||
const kept: MediaStream[] = [];
|
||||
for (const s of streams) {
|
||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||
if (!dec || dec.action === 'remove') removed.push(s);
|
||||
if (!dec || dec.action === "remove") removed.push(s);
|
||||
else kept.push(s);
|
||||
}
|
||||
return { removed, kept };
|
||||
@@ -477,8 +543,8 @@ export function streamLabel(s: MediaStream): string {
|
||||
if (s.codec) parts.push(s.codec);
|
||||
if (s.language_display || s.language) parts.push(s.language_display ?? s.language!);
|
||||
if (s.title) parts.push(`"${s.title}"`);
|
||||
if (s.type === 'Audio' && s.channels) parts.push(`${s.channels}ch`);
|
||||
if (s.is_forced) parts.push('forced');
|
||||
if (s.is_hearing_impaired) parts.push('CC');
|
||||
return parts.join(' · ');
|
||||
if (s.type === "Audio" && s.channels) parts.push(`${s.channels}ch`);
|
||||
if (s.is_forced) parts.push("forced");
|
||||
if (s.is_hearing_impaired) parts.push("CC");
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { JellyfinItem, JellyfinMediaStream, JellyfinUser, MediaStream } from '../types';
|
||||
import type { JellyfinItem, JellyfinMediaStream, JellyfinUser, MediaStream } from "../types";
|
||||
|
||||
export interface JellyfinConfig {
|
||||
url: string;
|
||||
@@ -16,8 +16,8 @@ const PAGE_SIZE = 200;
|
||||
|
||||
function headers(apiKey: string): Record<string, string> {
|
||||
return {
|
||||
'X-Emby-Token': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
"X-Emby-Token": apiKey,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,36 +33,36 @@ export async function testConnection(cfg: JellyfinConfig): Promise<{ ok: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUsers(cfg: Pick<JellyfinConfig, 'url' | 'apiKey'>): Promise<JellyfinUser[]> {
|
||||
export async function getUsers(cfg: Pick<JellyfinConfig, "url" | "apiKey">): Promise<JellyfinUser[]> {
|
||||
const res = await fetch(`${cfg.url}/Users`, { headers: headers(cfg.apiKey) });
|
||||
if (!res.ok) throw new Error(`Jellyfin /Users failed: ${res.status}`);
|
||||
return res.json() as Promise<JellyfinUser[]>;
|
||||
}
|
||||
|
||||
const ITEM_FIELDS = [
|
||||
'MediaStreams',
|
||||
'Path',
|
||||
'ProviderIds',
|
||||
'OriginalTitle',
|
||||
'ProductionYear',
|
||||
'Size',
|
||||
'Container',
|
||||
].join(',');
|
||||
"MediaStreams",
|
||||
"Path",
|
||||
"ProviderIds",
|
||||
"OriginalTitle",
|
||||
"ProductionYear",
|
||||
"Size",
|
||||
"Container",
|
||||
].join(",");
|
||||
|
||||
export async function* getAllItems(
|
||||
cfg: JellyfinConfig,
|
||||
onProgress?: (count: number, total: number) => void
|
||||
onProgress?: (count: number, total: number) => void,
|
||||
): AsyncGenerator<JellyfinItem> {
|
||||
let startIndex = 0;
|
||||
let total = 0;
|
||||
|
||||
do {
|
||||
const url = new URL(itemsBaseUrl(cfg));
|
||||
url.searchParams.set('Recursive', 'true');
|
||||
url.searchParams.set('IncludeItemTypes', 'Movie,Episode');
|
||||
url.searchParams.set('Fields', ITEM_FIELDS);
|
||||
url.searchParams.set('Limit', String(PAGE_SIZE));
|
||||
url.searchParams.set('StartIndex', String(startIndex));
|
||||
url.searchParams.set("Recursive", "true");
|
||||
url.searchParams.set("IncludeItemTypes", "Movie,Episode");
|
||||
url.searchParams.set("Fields", ITEM_FIELDS);
|
||||
url.searchParams.set("Limit", String(PAGE_SIZE));
|
||||
url.searchParams.set("StartIndex", String(startIndex));
|
||||
|
||||
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (!res.ok) throw new Error(`Jellyfin items failed: ${res.status}`);
|
||||
@@ -86,33 +86,34 @@ export async function* getAllItems(
|
||||
export async function* getDevItems(cfg: JellyfinConfig): AsyncGenerator<JellyfinItem> {
|
||||
// 50 random movies
|
||||
const movieUrl = new URL(itemsBaseUrl(cfg));
|
||||
movieUrl.searchParams.set('Recursive', 'true');
|
||||
movieUrl.searchParams.set('IncludeItemTypes', 'Movie');
|
||||
movieUrl.searchParams.set('SortBy', 'Random');
|
||||
movieUrl.searchParams.set('Limit', '50');
|
||||
movieUrl.searchParams.set('Fields', ITEM_FIELDS);
|
||||
movieUrl.searchParams.set("Recursive", "true");
|
||||
movieUrl.searchParams.set("IncludeItemTypes", "Movie");
|
||||
movieUrl.searchParams.set("SortBy", "Random");
|
||||
movieUrl.searchParams.set("Limit", "50");
|
||||
movieUrl.searchParams.set("Fields", ITEM_FIELDS);
|
||||
|
||||
const movieRes = await fetch(movieUrl.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (!movieRes.ok) throw new Error(`Jellyfin movies failed: HTTP ${movieRes.status} — check JELLYFIN_URL and JELLYFIN_API_KEY`);
|
||||
if (!movieRes.ok)
|
||||
throw new Error(`Jellyfin movies failed: HTTP ${movieRes.status} — check JELLYFIN_URL and JELLYFIN_API_KEY`);
|
||||
const movieBody = (await movieRes.json()) as { Items: JellyfinItem[] };
|
||||
for (const item of movieBody.Items) yield item;
|
||||
|
||||
// 10 random series → yield all their episodes
|
||||
const seriesUrl = new URL(itemsBaseUrl(cfg));
|
||||
seriesUrl.searchParams.set('Recursive', 'true');
|
||||
seriesUrl.searchParams.set('IncludeItemTypes', 'Series');
|
||||
seriesUrl.searchParams.set('SortBy', 'Random');
|
||||
seriesUrl.searchParams.set('Limit', '10');
|
||||
seriesUrl.searchParams.set("Recursive", "true");
|
||||
seriesUrl.searchParams.set("IncludeItemTypes", "Series");
|
||||
seriesUrl.searchParams.set("SortBy", "Random");
|
||||
seriesUrl.searchParams.set("Limit", "10");
|
||||
|
||||
const seriesRes = await fetch(seriesUrl.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (!seriesRes.ok) throw new Error(`Jellyfin series failed: HTTP ${seriesRes.status}`);
|
||||
const seriesBody = (await seriesRes.json()) as { Items: Array<{ Id: string }> };
|
||||
for (const series of seriesBody.Items) {
|
||||
const epUrl = new URL(itemsBaseUrl(cfg));
|
||||
epUrl.searchParams.set('ParentId', series.Id);
|
||||
epUrl.searchParams.set('Recursive', 'true');
|
||||
epUrl.searchParams.set('IncludeItemTypes', 'Episode');
|
||||
epUrl.searchParams.set('Fields', ITEM_FIELDS);
|
||||
epUrl.searchParams.set("ParentId", series.Id);
|
||||
epUrl.searchParams.set("Recursive", "true");
|
||||
epUrl.searchParams.set("IncludeItemTypes", "Episode");
|
||||
epUrl.searchParams.set("Fields", ITEM_FIELDS);
|
||||
|
||||
const epRes = await fetch(epUrl.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (epRes.ok) {
|
||||
@@ -126,7 +127,7 @@ export async function* getDevItems(cfg: JellyfinConfig): AsyncGenerator<Jellyfin
|
||||
export async function getItem(cfg: JellyfinConfig, jellyfinId: string): Promise<JellyfinItem | null> {
|
||||
const base = cfg.userId ? `${cfg.url}/Users/${cfg.userId}/Items/${jellyfinId}` : `${cfg.url}/Items/${jellyfinId}`;
|
||||
const url = new URL(base);
|
||||
url.searchParams.set('Fields', ITEM_FIELDS);
|
||||
url.searchParams.set("Fields", ITEM_FIELDS);
|
||||
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<JellyfinItem>;
|
||||
@@ -147,11 +148,11 @@ export async function refreshItem(cfg: JellyfinConfig, jellyfinId: string, timeo
|
||||
|
||||
// 2. Trigger refresh (returns 204 immediately; refresh runs async)
|
||||
const refreshUrl = new URL(`${itemUrl}/Refresh`);
|
||||
refreshUrl.searchParams.set('MetadataRefreshMode', 'FullRefresh');
|
||||
refreshUrl.searchParams.set('ImageRefreshMode', 'None');
|
||||
refreshUrl.searchParams.set('ReplaceAllMetadata', 'false');
|
||||
refreshUrl.searchParams.set('ReplaceAllImages', 'false');
|
||||
const refreshRes = await fetch(refreshUrl.toString(), { method: 'POST', headers: headers(cfg.apiKey) });
|
||||
refreshUrl.searchParams.set("MetadataRefreshMode", "FullRefresh");
|
||||
refreshUrl.searchParams.set("ImageRefreshMode", "None");
|
||||
refreshUrl.searchParams.set("ReplaceAllMetadata", "false");
|
||||
refreshUrl.searchParams.set("ReplaceAllImages", "false");
|
||||
const refreshRes = await fetch(refreshUrl.toString(), { method: "POST", headers: headers(cfg.apiKey) });
|
||||
if (!refreshRes.ok) throw new Error(`Jellyfin refresh failed: HTTP ${refreshRes.status}`);
|
||||
|
||||
// 3. Poll until DateLastRefreshed changes
|
||||
@@ -171,15 +172,15 @@ export function extractOriginalLanguage(item: JellyfinItem): string | null {
|
||||
// Jellyfin doesn't have a direct "original_language" field like TMDb.
|
||||
// The best proxy is the language of the first audio stream.
|
||||
if (!item.MediaStreams) return null;
|
||||
const firstAudio = item.MediaStreams.find((s) => s.Type === 'Audio');
|
||||
const firstAudio = item.MediaStreams.find((s) => s.Type === "Audio");
|
||||
return firstAudio?.Language ? normalizeLanguage(firstAudio.Language) : null;
|
||||
}
|
||||
|
||||
/** Map a Jellyfin MediaStream to our internal MediaStream shape (sans id/item_id). */
|
||||
export function mapStream(s: JellyfinMediaStream): Omit<MediaStream, 'id' | 'item_id'> {
|
||||
export function mapStream(s: JellyfinMediaStream): Omit<MediaStream, "id" | "item_id"> {
|
||||
return {
|
||||
stream_index: s.Index,
|
||||
type: s.Type as MediaStream['type'],
|
||||
type: s.Type as MediaStream["type"],
|
||||
codec: s.Codec ?? null,
|
||||
language: s.Language ? normalizeLanguage(s.Language) : null,
|
||||
language_display: s.DisplayLanguage ?? null,
|
||||
@@ -197,45 +198,45 @@ export function mapStream(s: JellyfinMediaStream): Omit<MediaStream, 'id' | 'ite
|
||||
// ISO 639-2/T → ISO 639-2/B normalization + common aliases
|
||||
const LANG_ALIASES: Record<string, string> = {
|
||||
// German: both /T (deu) and /B (ger) → deu
|
||||
ger: 'deu',
|
||||
ger: "deu",
|
||||
// Chinese
|
||||
chi: 'zho',
|
||||
chi: "zho",
|
||||
// French
|
||||
fre: 'fra',
|
||||
fre: "fra",
|
||||
// Dutch
|
||||
dut: 'nld',
|
||||
dut: "nld",
|
||||
// Modern Greek
|
||||
gre: 'ell',
|
||||
gre: "ell",
|
||||
// Hebrew
|
||||
heb: 'heb',
|
||||
heb: "heb",
|
||||
// Farsi
|
||||
per: 'fas',
|
||||
per: "fas",
|
||||
// Romanian
|
||||
rum: 'ron',
|
||||
rum: "ron",
|
||||
// Malay
|
||||
may: 'msa',
|
||||
may: "msa",
|
||||
// Tibetan
|
||||
tib: 'bod',
|
||||
tib: "bod",
|
||||
// Burmese
|
||||
bur: 'mya',
|
||||
bur: "mya",
|
||||
// Czech
|
||||
cze: 'ces',
|
||||
cze: "ces",
|
||||
// Slovak
|
||||
slo: 'slk',
|
||||
slo: "slk",
|
||||
// Georgian
|
||||
geo: 'kat',
|
||||
geo: "kat",
|
||||
// Icelandic
|
||||
ice: 'isl',
|
||||
ice: "isl",
|
||||
// Armenian
|
||||
arm: 'hye',
|
||||
arm: "hye",
|
||||
// Basque
|
||||
baq: 'eus',
|
||||
baq: "eus",
|
||||
// Albanian
|
||||
alb: 'sqi',
|
||||
alb: "sqi",
|
||||
// Macedonian
|
||||
mac: 'mkd',
|
||||
mac: "mkd",
|
||||
// Welsh
|
||||
wel: 'cym',
|
||||
wel: "cym",
|
||||
};
|
||||
|
||||
export function normalizeLanguage(lang: string): string {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeLanguage } from './jellyfin';
|
||||
import { normalizeLanguage } from "./jellyfin";
|
||||
|
||||
export interface RadarrConfig {
|
||||
url: string;
|
||||
@@ -6,7 +6,7 @@ export interface RadarrConfig {
|
||||
}
|
||||
|
||||
function headers(apiKey: string): Record<string, string> {
|
||||
return { 'X-Api-Key': apiKey };
|
||||
return { "X-Api-Key": apiKey };
|
||||
}
|
||||
|
||||
export async function testConnection(cfg: RadarrConfig): Promise<{ ok: boolean; error?: string }> {
|
||||
@@ -30,7 +30,7 @@ interface RadarrMovie {
|
||||
/** Returns ISO 639-2 original language or null. */
|
||||
export async function getOriginalLanguage(
|
||||
cfg: RadarrConfig,
|
||||
ids: { tmdbId?: string; imdbId?: string }
|
||||
ids: { tmdbId?: string; imdbId?: string },
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
let movie: RadarrMovie | null = null;
|
||||
@@ -65,41 +65,41 @@ export async function getOriginalLanguage(
|
||||
// Radarr returns language names like "English", "French", "German", etc.
|
||||
// Map them to ISO 639-2 codes.
|
||||
const NAME_TO_639_2: Record<string, string> = {
|
||||
english: 'eng',
|
||||
french: 'fra',
|
||||
german: 'deu',
|
||||
spanish: 'spa',
|
||||
italian: 'ita',
|
||||
portuguese: 'por',
|
||||
japanese: 'jpn',
|
||||
korean: 'kor',
|
||||
chinese: 'zho',
|
||||
arabic: 'ara',
|
||||
russian: 'rus',
|
||||
dutch: 'nld',
|
||||
swedish: 'swe',
|
||||
norwegian: 'nor',
|
||||
danish: 'dan',
|
||||
finnish: 'fin',
|
||||
polish: 'pol',
|
||||
turkish: 'tur',
|
||||
thai: 'tha',
|
||||
hindi: 'hin',
|
||||
hungarian: 'hun',
|
||||
czech: 'ces',
|
||||
romanian: 'ron',
|
||||
greek: 'ell',
|
||||
hebrew: 'heb',
|
||||
persian: 'fas',
|
||||
ukrainian: 'ukr',
|
||||
indonesian: 'ind',
|
||||
malay: 'msa',
|
||||
vietnamese: 'vie',
|
||||
catalan: 'cat',
|
||||
tamil: 'tam',
|
||||
telugu: 'tel',
|
||||
'brazilian portuguese': 'por',
|
||||
'portuguese (brazil)': 'por',
|
||||
english: "eng",
|
||||
french: "fra",
|
||||
german: "deu",
|
||||
spanish: "spa",
|
||||
italian: "ita",
|
||||
portuguese: "por",
|
||||
japanese: "jpn",
|
||||
korean: "kor",
|
||||
chinese: "zho",
|
||||
arabic: "ara",
|
||||
russian: "rus",
|
||||
dutch: "nld",
|
||||
swedish: "swe",
|
||||
norwegian: "nor",
|
||||
danish: "dan",
|
||||
finnish: "fin",
|
||||
polish: "pol",
|
||||
turkish: "tur",
|
||||
thai: "tha",
|
||||
hindi: "hin",
|
||||
hungarian: "hun",
|
||||
czech: "ces",
|
||||
romanian: "ron",
|
||||
greek: "ell",
|
||||
hebrew: "heb",
|
||||
persian: "fas",
|
||||
ukrainian: "ukr",
|
||||
indonesian: "ind",
|
||||
malay: "msa",
|
||||
vietnamese: "vie",
|
||||
catalan: "cat",
|
||||
tamil: "tam",
|
||||
telugu: "tel",
|
||||
"brazilian portuguese": "por",
|
||||
"portuguese (brazil)": "por",
|
||||
};
|
||||
|
||||
function iso6391To6392(name: string): string | null {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getConfig, setConfig } from '../db';
|
||||
import { getConfig, setConfig } from "../db";
|
||||
|
||||
export interface SchedulerState {
|
||||
job_sleep_seconds: number;
|
||||
@@ -9,18 +9,18 @@ export interface SchedulerState {
|
||||
|
||||
export function getSchedulerState(): SchedulerState {
|
||||
return {
|
||||
job_sleep_seconds: parseInt(getConfig('job_sleep_seconds') ?? '0', 10),
|
||||
schedule_enabled: getConfig('schedule_enabled') === '1',
|
||||
schedule_start: getConfig('schedule_start') ?? '01:00',
|
||||
schedule_end: getConfig('schedule_end') ?? '07:00',
|
||||
job_sleep_seconds: parseInt(getConfig("job_sleep_seconds") ?? "0", 10),
|
||||
schedule_enabled: getConfig("schedule_enabled") === "1",
|
||||
schedule_start: getConfig("schedule_start") ?? "01:00",
|
||||
schedule_end: getConfig("schedule_end") ?? "07:00",
|
||||
};
|
||||
}
|
||||
|
||||
export function updateSchedulerState(updates: Partial<SchedulerState>): void {
|
||||
if (updates.job_sleep_seconds != null) setConfig('job_sleep_seconds', String(updates.job_sleep_seconds));
|
||||
if (updates.schedule_enabled != null) setConfig('schedule_enabled', updates.schedule_enabled ? '1' : '0');
|
||||
if (updates.schedule_start != null) setConfig('schedule_start', updates.schedule_start);
|
||||
if (updates.schedule_end != null) setConfig('schedule_end', updates.schedule_end);
|
||||
if (updates.job_sleep_seconds != null) setConfig("job_sleep_seconds", String(updates.job_sleep_seconds));
|
||||
if (updates.schedule_enabled != null) setConfig("schedule_enabled", updates.schedule_enabled ? "1" : "0");
|
||||
if (updates.schedule_start != null) setConfig("schedule_start", updates.schedule_start);
|
||||
if (updates.schedule_end != null) setConfig("schedule_end", updates.schedule_end);
|
||||
}
|
||||
|
||||
/** Check if current time is within the schedule window. */
|
||||
@@ -63,7 +63,7 @@ export function nextWindowTime(): string {
|
||||
}
|
||||
|
||||
function parseTime(hhmm: string): number {
|
||||
const [h, m] = hhmm.split(':').map(Number);
|
||||
const [h, m] = hhmm.split(":").map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
@@ -71,12 +71,12 @@ function parseTime(hhmm: string): number {
|
||||
export function sleepBetweenJobs(): Promise<void> {
|
||||
const seconds = getSchedulerState().job_sleep_seconds;
|
||||
if (seconds <= 0) return Promise.resolve();
|
||||
return new Promise(resolve => setTimeout(resolve, seconds * 1000));
|
||||
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
||||
}
|
||||
|
||||
/** Wait until the schedule window opens. Resolves immediately if already in window. */
|
||||
export function waitForWindow(): Promise<void> {
|
||||
if (isInScheduleWindow()) return Promise.resolve();
|
||||
const ms = msUntilWindow();
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeLanguage } from './jellyfin';
|
||||
import { normalizeLanguage } from "./jellyfin";
|
||||
|
||||
export interface SonarrConfig {
|
||||
url: string;
|
||||
@@ -6,7 +6,7 @@ export interface SonarrConfig {
|
||||
}
|
||||
|
||||
function headers(apiKey: string): Record<string, string> {
|
||||
return { 'X-Api-Key': apiKey };
|
||||
return { "X-Api-Key": apiKey };
|
||||
}
|
||||
|
||||
export async function testConnection(cfg: SonarrConfig): Promise<{ ok: boolean; error?: string }> {
|
||||
@@ -27,10 +27,7 @@ interface SonarrSeries {
|
||||
}
|
||||
|
||||
/** Returns ISO 639-2 original language for a series or null. */
|
||||
export async function getOriginalLanguage(
|
||||
cfg: SonarrConfig,
|
||||
tvdbId: string
|
||||
): Promise<string | null> {
|
||||
export async function getOriginalLanguage(cfg: SonarrConfig, tvdbId: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`${cfg.url}/api/v3/series?tvdbId=${tvdbId}`, {
|
||||
headers: headers(cfg.apiKey),
|
||||
@@ -47,36 +44,36 @@ export async function getOriginalLanguage(
|
||||
}
|
||||
|
||||
const NAME_TO_639_2: Record<string, string> = {
|
||||
english: 'eng',
|
||||
french: 'fra',
|
||||
german: 'deu',
|
||||
spanish: 'spa',
|
||||
italian: 'ita',
|
||||
portuguese: 'por',
|
||||
japanese: 'jpn',
|
||||
korean: 'kor',
|
||||
chinese: 'zho',
|
||||
arabic: 'ara',
|
||||
russian: 'rus',
|
||||
dutch: 'nld',
|
||||
swedish: 'swe',
|
||||
norwegian: 'nor',
|
||||
danish: 'dan',
|
||||
finnish: 'fin',
|
||||
polish: 'pol',
|
||||
turkish: 'tur',
|
||||
thai: 'tha',
|
||||
hindi: 'hin',
|
||||
hungarian: 'hun',
|
||||
czech: 'ces',
|
||||
romanian: 'ron',
|
||||
greek: 'ell',
|
||||
hebrew: 'heb',
|
||||
persian: 'fas',
|
||||
ukrainian: 'ukr',
|
||||
indonesian: 'ind',
|
||||
malay: 'msa',
|
||||
vietnamese: 'vie',
|
||||
english: "eng",
|
||||
french: "fra",
|
||||
german: "deu",
|
||||
spanish: "spa",
|
||||
italian: "ita",
|
||||
portuguese: "por",
|
||||
japanese: "jpn",
|
||||
korean: "kor",
|
||||
chinese: "zho",
|
||||
arabic: "ara",
|
||||
russian: "rus",
|
||||
dutch: "nld",
|
||||
swedish: "swe",
|
||||
norwegian: "nor",
|
||||
danish: "dan",
|
||||
finnish: "fin",
|
||||
polish: "pol",
|
||||
turkish: "tur",
|
||||
thai: "tha",
|
||||
hindi: "hin",
|
||||
hungarian: "hun",
|
||||
czech: "ces",
|
||||
romanian: "ron",
|
||||
greek: "ell",
|
||||
hebrew: "heb",
|
||||
persian: "fas",
|
||||
ukrainian: "ukr",
|
||||
indonesian: "ind",
|
||||
malay: "msa",
|
||||
vietnamese: "vie",
|
||||
};
|
||||
|
||||
function languageNameToCode(name: string): string | null {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
export interface MediaItem {
|
||||
id: number;
|
||||
jellyfin_id: string;
|
||||
type: 'Movie' | 'Episode';
|
||||
type: "Movie" | "Episode";
|
||||
name: string;
|
||||
series_name: string | null;
|
||||
series_jellyfin_id: string | null;
|
||||
@@ -14,12 +14,12 @@ export interface MediaItem {
|
||||
file_size: number | null;
|
||||
container: string | null;
|
||||
original_language: string | null;
|
||||
orig_lang_source: 'jellyfin' | 'radarr' | 'sonarr' | 'manual' | null;
|
||||
orig_lang_source: "jellyfin" | "radarr" | "sonarr" | "manual" | null;
|
||||
needs_review: number;
|
||||
imdb_id: string | null;
|
||||
tmdb_id: string | null;
|
||||
tvdb_id: string | null;
|
||||
scan_status: 'pending' | 'scanned' | 'error';
|
||||
scan_status: "pending" | "scanned" | "error";
|
||||
scan_error: string | null;
|
||||
last_scanned_at: string | null;
|
||||
created_at: string;
|
||||
@@ -29,7 +29,7 @@ export interface MediaStream {
|
||||
id: number;
|
||||
item_id: number;
|
||||
stream_index: number;
|
||||
type: 'Video' | 'Audio' | 'Subtitle' | 'Data' | 'EmbeddedImage';
|
||||
type: "Video" | "Audio" | "Subtitle" | "Data" | "EmbeddedImage";
|
||||
codec: string | null;
|
||||
language: string | null;
|
||||
language_display: string | null;
|
||||
@@ -46,11 +46,11 @@ export interface MediaStream {
|
||||
export interface ReviewPlan {
|
||||
id: number;
|
||||
item_id: number;
|
||||
status: 'pending' | 'approved' | 'skipped' | 'done' | 'error';
|
||||
status: "pending" | "approved" | "skipped" | "done" | "error";
|
||||
is_noop: number;
|
||||
confidence: 'high' | 'low';
|
||||
apple_compat: 'direct_play' | 'remux' | 'audio_transcode' | null;
|
||||
job_type: 'copy' | 'transcode';
|
||||
confidence: "high" | "low";
|
||||
apple_compat: "direct_play" | "remux" | "audio_transcode" | null;
|
||||
job_type: "copy" | "transcode";
|
||||
subs_extracted: number;
|
||||
notes: string | null;
|
||||
reviewed_at: string | null;
|
||||
@@ -73,7 +73,7 @@ export interface StreamDecision {
|
||||
id: number;
|
||||
plan_id: number;
|
||||
stream_id: number;
|
||||
action: 'keep' | 'remove';
|
||||
action: "keep" | "remove";
|
||||
target_index: number | null;
|
||||
custom_title: string | null;
|
||||
transcode_codec: string | null;
|
||||
@@ -83,8 +83,8 @@ export interface Job {
|
||||
id: number;
|
||||
item_id: number;
|
||||
command: string;
|
||||
job_type: 'copy' | 'transcode';
|
||||
status: 'pending' | 'running' | 'done' | 'error';
|
||||
job_type: "copy" | "transcode";
|
||||
status: "pending" | "running" | "done" | "error";
|
||||
output: string | null;
|
||||
exit_code: number | null;
|
||||
created_at: string;
|
||||
@@ -95,17 +95,22 @@ export interface Job {
|
||||
// ─── Analyzer types ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface StreamWithDecision extends MediaStream {
|
||||
action: 'keep' | 'remove';
|
||||
action: "keep" | "remove";
|
||||
target_index: number | null;
|
||||
}
|
||||
|
||||
export interface PlanResult {
|
||||
is_noop: boolean;
|
||||
has_subs: boolean;
|
||||
confidence: 'high' | 'low';
|
||||
apple_compat: 'direct_play' | 'remux' | 'audio_transcode' | null;
|
||||
job_type: 'copy' | 'transcode';
|
||||
decisions: Array<{ stream_id: number; action: 'keep' | 'remove'; target_index: number | null; transcode_codec: string | null }>;
|
||||
confidence: "high" | "low";
|
||||
apple_compat: "direct_play" | "remux" | "audio_transcode" | null;
|
||||
job_type: "copy" | "transcode";
|
||||
decisions: Array<{
|
||||
stream_id: number;
|
||||
action: "keep" | "remove";
|
||||
target_index: number | null;
|
||||
transcode_codec: string | null;
|
||||
}>;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
@@ -161,7 +166,7 @@ export interface ScanProgress {
|
||||
|
||||
// ─── SSE event helpers ────────────────────────────────────────────────────────
|
||||
|
||||
export type SseEventType = 'progress' | 'log' | 'complete' | 'error';
|
||||
export type SseEventType = "progress" | "log" | "complete" | "error";
|
||||
|
||||
export interface SseEvent {
|
||||
type: SseEventType;
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from '@tanstack/react-router';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
import { Alert } from '~/shared/components/ui/alert';
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert } from "~/shared/components/ui/alert";
|
||||
import { Button } from "~/shared/components/ui/button";
|
||||
import { api } from "~/shared/lib/api";
|
||||
|
||||
interface Stats {
|
||||
totalItems: number; scanned: number; needsAction: number;
|
||||
approved: number; done: number; errors: number; noChange: number;
|
||||
totalItems: number;
|
||||
scanned: number;
|
||||
needsAction: number;
|
||||
approved: number;
|
||||
done: number;
|
||||
errors: number;
|
||||
noChange: number;
|
||||
}
|
||||
|
||||
interface DashboardData { stats: Stats; scanRunning: boolean; setupComplete: boolean; }
|
||||
interface DashboardData {
|
||||
stats: Stats;
|
||||
scanRunning: boolean;
|
||||
setupComplete: boolean;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, danger }: { label: string; value: number; danger?: boolean }) {
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg px-3.5 py-2.5">
|
||||
<div className={`text-[1.6rem] font-bold leading-[1.1] ${danger ? 'text-red-600' : ''}`}>
|
||||
<div className={`text-[1.6rem] font-bold leading-[1.1] ${danger ? "text-red-600" : ""}`}>
|
||||
{value.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-[0.7rem] text-gray-500 mt-0.5">{label}</div>
|
||||
@@ -29,17 +38,20 @@ export function DashboardPage() {
|
||||
const [starting, setStarting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<DashboardData>('/api/dashboard').then((d) => {
|
||||
api
|
||||
.get<DashboardData>("/api/dashboard")
|
||||
.then((d) => {
|
||||
setData(d);
|
||||
setLoading(false);
|
||||
if (!d.setupComplete) navigate({ to: '/setup' });
|
||||
}).catch(() => setLoading(false));
|
||||
if (!d.setupComplete) navigate({ to: "/setup" });
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, [navigate]);
|
||||
|
||||
const startScan = async () => {
|
||||
setStarting(true);
|
||||
await api.post('/api/scan/start', {}).catch(() => {});
|
||||
navigate({ to: '/scan' });
|
||||
await api.post("/api/scan/start", {}).catch(() => {});
|
||||
navigate({ to: "/scan" });
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
||||
@@ -65,18 +77,27 @@ export function DashboardPage() {
|
||||
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
{scanRunning ? (
|
||||
<Link to="/scan" className="inline-flex items-center justify-center gap-1 rounded px-3 py-1.5 text-sm font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline">
|
||||
<Link
|
||||
to="/scan"
|
||||
className="inline-flex items-center justify-center gap-1 rounded px-3 py-1.5 text-sm font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline"
|
||||
>
|
||||
⏳ Scan running…
|
||||
</Link>
|
||||
) : (
|
||||
<Button onClick={startScan} disabled={starting}>
|
||||
{starting ? 'Starting…' : '▶ Start Scan'}
|
||||
{starting ? "Starting…" : "▶ Start Scan"}
|
||||
</Button>
|
||||
)}
|
||||
<Link to="/review" className="inline-flex items-center justify-center gap-1 rounded px-3 py-1.5 text-sm font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline">
|
||||
<Link
|
||||
to="/review"
|
||||
className="inline-flex items-center justify-center gap-1 rounded px-3 py-1.5 text-sm font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline"
|
||||
>
|
||||
Review changes
|
||||
</Link>
|
||||
<Link to="/execute" className="inline-flex items-center justify-center gap-1 rounded px-3 py-1.5 text-sm font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline">
|
||||
<Link
|
||||
to="/execute"
|
||||
className="inline-flex items-center justify-center gap-1 rounded px-3 py-1.5 text-sm font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline"
|
||||
>
|
||||
Execute jobs
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,39 +1,46 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
import { FilterTabs } from '~/shared/components/ui/filter-tabs';
|
||||
import type { Job, MediaItem } from '~/shared/lib/types';
|
||||
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { useEffect, useRef, 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 type { Job, MediaItem } from "~/shared/lib/types";
|
||||
|
||||
interface JobEntry { job: Job; item: MediaItem | null; }
|
||||
interface ExecuteData { jobs: JobEntry[]; filter: string; totalCounts: Record<string, number>; }
|
||||
interface JobEntry {
|
||||
job: Job;
|
||||
item: MediaItem | null;
|
||||
}
|
||||
interface ExecuteData {
|
||||
jobs: JobEntry[];
|
||||
filter: string;
|
||||
totalCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
const FILTER_TABS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'pending', label: 'Pending' },
|
||||
{ key: 'running', label: 'Running' },
|
||||
{ key: 'done', label: 'Done' },
|
||||
{ key: 'error', label: 'Error' },
|
||||
{ key: "all", label: "All" },
|
||||
{ key: "pending", label: "Pending" },
|
||||
{ key: "running", label: "Running" },
|
||||
{ key: "done", label: "Done" },
|
||||
{ key: "error", label: "Error" },
|
||||
];
|
||||
|
||||
function itemName(job: Job, item: MediaItem | null): string {
|
||||
if (!item) return `Item #${job.item_id}`;
|
||||
if (item.type === 'Episode' && item.series_name) {
|
||||
return `${item.series_name} S${String(item.season_number ?? 0).padStart(2, '0')}E${String(item.episode_number ?? 0).padStart(2, '0')}`;
|
||||
if (item.type === "Episode" && item.series_name) {
|
||||
return `${item.series_name} S${String(item.season_number ?? 0).padStart(2, "0")}E${String(item.episode_number ?? 0).padStart(2, "0")}`;
|
||||
}
|
||||
return item.name;
|
||||
}
|
||||
|
||||
function jobTypeLabel(job: Job): string {
|
||||
return job.job_type === 'subtitle' ? 'ST Extract' : 'Audio Mod';
|
||||
return job.job_type === "subtitle" ? "ST Extract" : "Audio Mod";
|
||||
}
|
||||
|
||||
// Module-level cache for instant tab switching
|
||||
const cache = new Map<string, ExecuteData>();
|
||||
|
||||
export function ExecutePage() {
|
||||
const { filter } = useSearch({ from: '/execute' });
|
||||
const { filter } = useSearch({ from: "/execute" });
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<ExecuteData | null>(cache.get(filter) ?? null);
|
||||
const [loading, setLoading] = useState(!cache.has(filter));
|
||||
@@ -46,22 +53,35 @@ export function ExecutePage() {
|
||||
const load = (f?: string) => {
|
||||
const key = f ?? filter;
|
||||
const cached = cache.get(key);
|
||||
if (cached && key === filter) { setData(cached); setLoading(false); }
|
||||
else if (key === filter) { setLoading(true); }
|
||||
api.get<ExecuteData>(`/api/execute?filter=${key}`)
|
||||
.then((d) => { cache.set(key, d); if (key === filter) { setData(d); setLoading(false); } })
|
||||
.catch(() => { if (key === filter) setLoading(false); });
|
||||
if (cached && key === filter) {
|
||||
setData(cached);
|
||||
setLoading(false);
|
||||
} else if (key === filter) {
|
||||
setLoading(true);
|
||||
}
|
||||
api
|
||||
.get<ExecuteData>(`/api/execute?filter=${key}`)
|
||||
.then((d) => {
|
||||
cache.set(key, d);
|
||||
if (key === filter) {
|
||||
setData(d);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (key === filter) setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [filter]);
|
||||
}, [load]);
|
||||
|
||||
// SSE for live job updates
|
||||
useEffect(() => {
|
||||
const es = new EventSource('/api/execute/events');
|
||||
const es = new EventSource("/api/execute/events");
|
||||
esRef.current = es;
|
||||
es.addEventListener('job_update', (e) => {
|
||||
es.addEventListener("job_update", (e) => {
|
||||
const d = JSON.parse(e.data) as { id: number; status: string; output?: string };
|
||||
|
||||
// Update job in current list if present
|
||||
@@ -71,7 +91,7 @@ export function ExecutePage() {
|
||||
if (jobIdx === -1) return prev;
|
||||
|
||||
const oldStatus = prev.jobs[jobIdx].job.status;
|
||||
const newStatus = d.status as Job['status'];
|
||||
const newStatus = d.status as Job["status"];
|
||||
|
||||
// Live-update totalCounts
|
||||
const newCounts = { ...prev.totalCounts };
|
||||
@@ -84,18 +104,20 @@ export function ExecutePage() {
|
||||
return {
|
||||
...prev,
|
||||
totalCounts: newCounts,
|
||||
jobs: prev.jobs.map((j) =>
|
||||
j.job.id === d.id ? { ...j, job: { ...j.job, status: newStatus } } : j
|
||||
),
|
||||
jobs: prev.jobs.map((j) => (j.job.id === d.id ? { ...j, job: { ...j.job, status: newStatus } } : j)),
|
||||
};
|
||||
});
|
||||
|
||||
if (d.output !== undefined) {
|
||||
setLogs((prev) => { const m = new Map(prev); m.set(d.id, d.output!); return m; });
|
||||
setLogs((prev) => {
|
||||
const m = new Map(prev);
|
||||
m.set(d.id, d.output!);
|
||||
return m;
|
||||
});
|
||||
}
|
||||
|
||||
// Debounced reload on terminal state for accurate list
|
||||
if (d.status === 'done' || d.status === 'error') {
|
||||
if (d.status === "done" || d.status === "error") {
|
||||
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
|
||||
reloadTimerRef.current = setTimeout(() => {
|
||||
// Invalidate cache and reload current filter
|
||||
@@ -104,17 +126,50 @@ export function ExecutePage() {
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
return () => { es.close(); if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current); };
|
||||
}, [filter]);
|
||||
return () => {
|
||||
es.close();
|
||||
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const startAll = async () => { await api.post('/api/execute/start'); cache.clear(); load(); };
|
||||
const clearQueue = async () => { await api.post('/api/execute/clear'); cache.clear(); load(); };
|
||||
const clearCompleted = async () => { await api.post('/api/execute/clear-completed'); cache.clear(); load(); };
|
||||
const runJob = async (id: number) => { await api.post(`/api/execute/job/${id}/run`); cache.clear(); load(); };
|
||||
const cancelJob = async (id: number) => { await api.post(`/api/execute/job/${id}/cancel`); cache.clear(); load(); };
|
||||
const startAll = async () => {
|
||||
await api.post("/api/execute/start");
|
||||
cache.clear();
|
||||
load();
|
||||
};
|
||||
const clearQueue = async () => {
|
||||
await api.post("/api/execute/clear");
|
||||
cache.clear();
|
||||
load();
|
||||
};
|
||||
const clearCompleted = async () => {
|
||||
await api.post("/api/execute/clear-completed");
|
||||
cache.clear();
|
||||
load();
|
||||
};
|
||||
const runJob = async (id: number) => {
|
||||
await api.post(`/api/execute/job/${id}/run`);
|
||||
cache.clear();
|
||||
load();
|
||||
};
|
||||
const cancelJob = async (id: number) => {
|
||||
await api.post(`/api/execute/job/${id}/cancel`);
|
||||
cache.clear();
|
||||
load();
|
||||
};
|
||||
|
||||
const toggleLog = (id: number) => setLogVisible((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
|
||||
const toggleCmd = (id: number) => setCmdVisible((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
|
||||
const toggleLog = (id: number) =>
|
||||
setLogVisible((prev) => {
|
||||
const s = new Set(prev);
|
||||
s.has(id) ? s.delete(id) : s.add(id);
|
||||
return s;
|
||||
});
|
||||
const toggleCmd = (id: number) =>
|
||||
setCmdVisible((prev) => {
|
||||
const s = new Set(prev);
|
||||
s.has(id) ? s.delete(id) : s.add(id);
|
||||
return s;
|
||||
});
|
||||
|
||||
const totalCounts = data?.totalCounts ?? { all: 0, pending: 0, running: 0, done: 0, error: 0 };
|
||||
const pending = totalCounts.pending ?? 0;
|
||||
@@ -130,27 +185,31 @@ export function ExecutePage() {
|
||||
<h1 className="text-xl font-bold mb-4">Execute Jobs</h1>
|
||||
|
||||
<div className="border border-gray-200 rounded-lg px-4 py-3 mb-6 flex items-center gap-3 flex-wrap">
|
||||
{totalCounts.all === 0 && !loading && (
|
||||
<span className="text-sm text-gray-500">No jobs yet.</span>
|
||||
)}
|
||||
{totalCounts.all === 0 && loading && (
|
||||
<span className="text-sm text-gray-400">Loading...</span>
|
||||
)}
|
||||
{allDone && (
|
||||
<span className="text-sm font-medium">All jobs completed</span>
|
||||
)}
|
||||
{totalCounts.all === 0 && !loading && <span className="text-sm text-gray-500">No jobs yet.</span>}
|
||||
{totalCounts.all === 0 && loading && <span className="text-sm text-gray-400">Loading...</span>}
|
||||
{allDone && <span className="text-sm font-medium">All jobs completed</span>}
|
||||
{running > 0 && (
|
||||
<span className="text-sm font-medium">{running} job{running !== 1 ? 's' : ''} running</span>
|
||||
<span className="text-sm font-medium">
|
||||
{running} job{running !== 1 ? "s" : ""} running
|
||||
</span>
|
||||
)}
|
||||
{pending > 0 && (
|
||||
<>
|
||||
<span className="text-sm font-medium">{pending} job{pending !== 1 ? 's' : ''} pending</span>
|
||||
<Button size="sm" onClick={startAll}>Run all pending</Button>
|
||||
<Button size="sm" variant="secondary" onClick={clearQueue}>Clear queue</Button>
|
||||
<span className="text-sm font-medium">
|
||||
{pending} job{pending !== 1 ? "s" : ""} pending
|
||||
</span>
|
||||
<Button size="sm" onClick={startAll}>
|
||||
Run all pending
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={clearQueue}>
|
||||
Clear queue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(done > 0 || errors > 0) && (
|
||||
<Button size="sm" variant="secondary" onClick={clearCompleted}>Clear done/errors</Button>
|
||||
<Button size="sm" variant="secondary" onClick={clearCompleted}>
|
||||
Clear done/errors
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -158,25 +217,31 @@ export function ExecutePage() {
|
||||
tabs={FILTER_TABS}
|
||||
filter={filter}
|
||||
totalCounts={totalCounts}
|
||||
onFilterChange={(key) => navigate({ to: '/execute', search: { filter: key } as never })}
|
||||
onFilterChange={(key) => navigate({ to: "/execute", search: { filter: key } as never })}
|
||||
/>
|
||||
|
||||
{loading && !data && <div className="text-gray-400 py-8 text-center">Loading…</div>}
|
||||
|
||||
{jobs.length > 0 && (
|
||||
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0"><table className="w-full border-collapse text-[0.82rem]">
|
||||
<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>
|
||||
{['#', 'Item', 'Type', 'Status', 'Actions'].map((h) => (
|
||||
<th key={h} 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">{h}</th>
|
||||
{["#", "Item", "Type", "Status", "Actions"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
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"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map(({ job, item }: JobEntry) => {
|
||||
const name = itemName(job, item);
|
||||
const jobLog = logs.get(job.id) ?? job.output ?? '';
|
||||
const showLog = logVisible.has(job.id) || job.status === 'running' || job.status === 'error';
|
||||
const jobLog = logs.get(job.id) ?? job.output ?? "";
|
||||
const showLog = logVisible.has(job.id) || job.status === "running" || job.status === "error";
|
||||
const showCmd = cmdVisible.has(job.id);
|
||||
|
||||
return (
|
||||
@@ -186,28 +251,48 @@ export function ExecutePage() {
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
<div className="truncate max-w-[300px]" title={name}>
|
||||
{item ? (
|
||||
<Link to="/review/audio/$id" params={{ id: String(item.id) }} className="text-inherit no-underline hover:underline">{name}</Link>
|
||||
) : name}
|
||||
<Link
|
||||
to="/review/audio/$id"
|
||||
params={{ id: String(item.id) }}
|
||||
className="text-inherit no-underline hover:underline"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
) : (
|
||||
name
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap">
|
||||
<Badge variant={job.job_type === 'subtitle' ? 'noop' : 'default'}>{jobTypeLabel(job)}</Badge>
|
||||
<Badge variant={job.job_type === "subtitle" ? "noop" : "default"}>{jobTypeLabel(job)}</Badge>
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
<Badge variant={job.status}>{job.status}</Badge>
|
||||
{job.exit_code != null && job.exit_code !== 0 && <Badge variant="error" className="ml-1">exit {job.exit_code}</Badge>}
|
||||
{job.exit_code != null && job.exit_code !== 0 && (
|
||||
<Badge variant="error" className="ml-1">
|
||||
exit {job.exit_code}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap">
|
||||
<div className="flex gap-1 items-center">
|
||||
{job.status === 'pending' && (
|
||||
{job.status === "pending" && (
|
||||
<>
|
||||
<Button size="sm" onClick={() => runJob(job.id)}>▶ Run</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => cancelJob(job.id)}>✕</Button>
|
||||
<Button size="sm" onClick={() => runJob(job.id)}>
|
||||
▶ Run
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => cancelJob(job.id)}>
|
||||
✕
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button size="sm" variant="secondary" onClick={() => toggleCmd(job.id)}>Cmd</Button>
|
||||
{(job.status === 'done' || job.status === 'error') && jobLog && (
|
||||
<Button size="sm" variant="secondary" onClick={() => toggleLog(job.id)}>Log</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => toggleCmd(job.id)}>
|
||||
Cmd
|
||||
</Button>
|
||||
{(job.status === "done" || job.status === "error") && jobLog && (
|
||||
<Button size="sm" variant="secondary" onClick={() => toggleLog(job.id)}>
|
||||
Log
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
@@ -234,7 +319,8 @@ export function ExecutePage() {
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table></div>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && jobs.length === 0 && totalCounts.all > 0 && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "~/shared/components/ui/badge";
|
||||
import { Button } from "~/shared/components/ui/button";
|
||||
import { api } from "~/shared/lib/api";
|
||||
|
||||
interface PathInfo {
|
||||
prefix: string;
|
||||
@@ -17,12 +17,18 @@ export function PathsPage() {
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
api.get<{ paths: PathInfo[] }>('/api/paths')
|
||||
.then((d) => { cache = d.paths; setPaths(d.paths); })
|
||||
api
|
||||
.get<{ paths: PathInfo[] }>("/api/paths")
|
||||
.then((d) => {
|
||||
cache = d.paths;
|
||||
setPaths(d.paths);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { if (cache === null) load(); }, []);
|
||||
useEffect(() => {
|
||||
if (cache === null) load();
|
||||
}, [load]);
|
||||
|
||||
const allGood = paths.length > 0 && paths.every((p) => p.accessible);
|
||||
const hasBroken = paths.some((p) => !p.accessible);
|
||||
@@ -35,17 +41,16 @@ export function PathsPage() {
|
||||
{paths.length === 0 && !loading && (
|
||||
<span className="text-sm text-gray-500">No media items scanned yet. Run a scan first.</span>
|
||||
)}
|
||||
{paths.length === 0 && loading && (
|
||||
<span className="text-sm text-gray-400">Checking paths...</span>
|
||||
)}
|
||||
{allGood && (
|
||||
<span className="text-sm font-medium">All {paths.length} paths accessible</span>
|
||||
)}
|
||||
{paths.length === 0 && loading && <span className="text-sm text-gray-400">Checking paths...</span>}
|
||||
{allGood && <span className="text-sm font-medium">All {paths.length} paths accessible</span>}
|
||||
{hasBroken && (
|
||||
<span className="text-sm font-medium text-red-800">{paths.filter((p) => !p.accessible).length} path{paths.filter((p) => !p.accessible).length !== 1 ? 's' : ''} not mounted</span>
|
||||
<span className="text-sm font-medium text-red-800">
|
||||
{paths.filter((p) => !p.accessible).length} path{paths.filter((p) => !p.accessible).length !== 1 ? "s" : ""} not
|
||||
mounted
|
||||
</span>
|
||||
)}
|
||||
<Button variant="secondary" size="sm" onClick={load} disabled={loading}>
|
||||
{loading ? 'Checking...' : 'Refresh'}
|
||||
{loading ? "Checking..." : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -65,11 +70,7 @@ export function PathsPage() {
|
||||
<td className="py-2 pr-4 font-mono text-sm">{p.prefix}</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">{p.itemCount}</td>
|
||||
<td className="py-2">
|
||||
{p.accessible ? (
|
||||
<Badge variant="keep">Accessible</Badge>
|
||||
) : (
|
||||
<Badge variant="error">Not mounted</Badge>
|
||||
)}
|
||||
{p.accessible ? <Badge variant="keep">Accessible</Badge> : <Badge variant="error">Not mounted</Badge>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -78,8 +79,8 @@ export function PathsPage() {
|
||||
|
||||
{paths.some((p) => !p.accessible) && (
|
||||
<p className="mt-4 text-xs text-gray-500">
|
||||
Paths marked "Not mounted" are not reachable from the container.
|
||||
Mount each path into the Docker container exactly as Jellyfin reports it.
|
||||
Paths marked "Not mounted" are not reachable from the container. Mount each path into the Docker container
|
||||
exactly as Jellyfin reports it.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
import { Badge } from "~/shared/components/ui/badge";
|
||||
|
||||
interface DoneColumnProps {
|
||||
items: any[];
|
||||
@@ -14,14 +14,10 @@ export function DoneColumn({ items }: DoneColumnProps) {
|
||||
{items.map((item: any) => (
|
||||
<div key={item.id} className="rounded border bg-white p-2">
|
||||
<p className="text-xs font-medium truncate">{item.name}</p>
|
||||
<Badge variant={item.status === 'done' ? 'done' : 'error'}>
|
||||
{item.status}
|
||||
</Badge>
|
||||
<Badge variant={item.status === "done" ? "done" : "error"}>{item.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<p className="text-sm text-gray-400 text-center py-8">No completed items</p>
|
||||
)}
|
||||
{items.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No completed items</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
import { LANG_NAMES, langName } from '~/shared/lib/lang';
|
||||
import { Badge } from "~/shared/components/ui/badge";
|
||||
import { LANG_NAMES, langName } from "~/shared/lib/lang";
|
||||
|
||||
interface PipelineCardProps {
|
||||
item: any;
|
||||
@@ -9,15 +9,15 @@ interface PipelineCardProps {
|
||||
}
|
||||
|
||||
export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApproveUpTo }: PipelineCardProps) {
|
||||
const title = item.type === 'Episode'
|
||||
? `S${String(item.season_number).padStart(2, '0')}E${String(item.episode_number).padStart(2, '0')} — ${item.name}`
|
||||
const title =
|
||||
item.type === "Episode"
|
||||
? `S${String(item.season_number).padStart(2, "0")}E${String(item.episode_number).padStart(2, "0")} — ${item.name}`
|
||||
: item.name;
|
||||
|
||||
const confidenceColor = item.confidence === 'high' ? 'bg-green-50 border-green-200' : 'bg-amber-50 border-amber-200';
|
||||
const confidenceColor = item.confidence === "high" ? "bg-green-50 border-green-200" : "bg-amber-50 border-amber-200";
|
||||
|
||||
const jellyfinLink = jellyfinUrl && item.jellyfin_id
|
||||
? `${jellyfinUrl}/web/index.html#!/details?id=${item.jellyfin_id}`
|
||||
: null;
|
||||
const jellyfinLink =
|
||||
jellyfinUrl && item.jellyfin_id ? `${jellyfinUrl}/web/index.html#!/details?id=${item.jellyfin_id}` : null;
|
||||
|
||||
return (
|
||||
<div className={`group rounded-lg border p-3 ${confidenceColor}`}>
|
||||
@@ -40,12 +40,14 @@ export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApproveUpT
|
||||
{onLanguageChange ? (
|
||||
<select
|
||||
className="h-6 text-xs border border-gray-300 rounded px-1 bg-white"
|
||||
value={item.original_language ?? ''}
|
||||
value={item.original_language ?? ""}
|
||||
onChange={(e) => onLanguageChange(e.target.value)}
|
||||
>
|
||||
<option value="">unknown</option>
|
||||
{Object.entries(LANG_NAMES).map(([code, name]) => (
|
||||
<option key={code} value={code}>{name}</option>
|
||||
<option key={code} value={code}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
@@ -54,12 +56,11 @@ export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApproveUpT
|
||||
|
||||
{item.transcode_reasons?.length > 0
|
||||
? item.transcode_reasons.map((r: string) => (
|
||||
<Badge key={r} variant="manual">{r}</Badge>
|
||||
<Badge key={r} variant="manual">
|
||||
{r}
|
||||
</Badge>
|
||||
))
|
||||
: item.job_type === 'copy' && (
|
||||
<Badge variant="noop">copy</Badge>
|
||||
)
|
||||
}
|
||||
: item.job_type === "copy" && <Badge variant="noop">copy</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { ReviewColumn } from './ReviewColumn';
|
||||
import { QueueColumn } from './QueueColumn';
|
||||
import { ProcessingColumn } from './ProcessingColumn';
|
||||
import { DoneColumn } from './DoneColumn';
|
||||
import { ScheduleControls } from './ScheduleControls';
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import { DoneColumn } from "./DoneColumn";
|
||||
import { ProcessingColumn } from "./ProcessingColumn";
|
||||
import { QueueColumn } from "./QueueColumn";
|
||||
import { ReviewColumn } from "./ReviewColumn";
|
||||
import { ScheduleControls } from "./ScheduleControls";
|
||||
|
||||
interface PipelineData {
|
||||
review: any[];
|
||||
@@ -43,24 +43,26 @@ export function PipelinePage() {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [pipelineRes, schedulerRes] = await Promise.all([
|
||||
api.get<PipelineData>('/api/review/pipeline'),
|
||||
api.get<SchedulerState>('/api/execute/scheduler'),
|
||||
api.get<PipelineData>("/api/review/pipeline"),
|
||||
api.get<SchedulerState>("/api/execute/scheduler"),
|
||||
]);
|
||||
setData(pipelineRes);
|
||||
setScheduler(schedulerRes);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// SSE for live updates
|
||||
useEffect(() => {
|
||||
const es = new EventSource('/api/execute/events');
|
||||
es.addEventListener('job_update', () => load());
|
||||
es.addEventListener('job_progress', (e) => {
|
||||
const es = new EventSource("/api/execute/events");
|
||||
es.addEventListener("job_update", () => load());
|
||||
es.addEventListener("job_progress", (e) => {
|
||||
setProgress(JSON.parse((e as MessageEvent).data));
|
||||
});
|
||||
es.addEventListener('queue_status', (e) => {
|
||||
es.addEventListener("queue_status", (e) => {
|
||||
setQueueStatus(JSON.parse((e as MessageEvent).data));
|
||||
});
|
||||
return () => es.close();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
import { Badge } from "~/shared/components/ui/badge";
|
||||
|
||||
interface ProcessingColumnProps {
|
||||
items: any[];
|
||||
@@ -12,18 +12,18 @@ export function ProcessingColumn({ items, progress, queueStatus }: ProcessingCol
|
||||
const formatTime = (s: number) => {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${String(sec).padStart(2, '0')}`;
|
||||
return `${m}:${String(sec).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-72 min-w-72 min-h-0 bg-gray-50 rounded-lg">
|
||||
<div className="px-3 py-2 border-b font-medium text-sm">Processing</div>
|
||||
<div className="flex-1 p-3">
|
||||
{queueStatus && queueStatus.status !== 'running' && (
|
||||
{queueStatus && queueStatus.status !== "running" && (
|
||||
<div className="mb-3 text-xs text-gray-500 bg-white rounded border p-2">
|
||||
{queueStatus.status === 'paused' && <>Paused until {queueStatus.until}</>}
|
||||
{queueStatus.status === 'sleeping' && <>Sleeping {queueStatus.seconds}s between jobs</>}
|
||||
{queueStatus.status === 'idle' && <>Idle</>}
|
||||
{queueStatus.status === "paused" && <>Paused until {queueStatus.until}</>}
|
||||
{queueStatus.status === "sleeping" && <>Sleeping {queueStatus.seconds}s between jobs</>}
|
||||
{queueStatus.status === "idle" && <>Idle</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -32,9 +32,7 @@ export function ProcessingColumn({ items, progress, queueStatus }: ProcessingCol
|
||||
<p className="text-sm font-medium truncate">{job.name}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="running">running</Badge>
|
||||
<Badge variant={job.job_type === 'transcode' ? 'manual' : 'noop'}>
|
||||
{job.job_type}
|
||||
</Badge>
|
||||
<Badge variant={job.job_type === "transcode" ? "manual" : "noop"}>{job.job_type}</Badge>
|
||||
</div>
|
||||
|
||||
{progress && progress.total > 0 && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
import { Badge } from "~/shared/components/ui/badge";
|
||||
|
||||
interface QueueColumnProps {
|
||||
items: any[];
|
||||
@@ -14,14 +14,10 @@ export function QueueColumn({ items }: QueueColumnProps) {
|
||||
{items.map((item: any) => (
|
||||
<div key={item.id} className="rounded border bg-white p-2">
|
||||
<p className="text-xs font-medium truncate">{item.name}</p>
|
||||
<Badge variant={item.job_type === 'transcode' ? 'manual' : 'noop'}>
|
||||
{item.job_type}
|
||||
</Badge>
|
||||
<Badge variant={item.job_type === "transcode" ? "manual" : "noop"}>{item.job_type}</Badge>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<p className="text-sm text-gray-400 text-center py-8">Queue empty</p>
|
||||
)}
|
||||
{items.length === 0 && <p className="text-sm text-gray-400 text-center py-8">Queue empty</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { PipelineCard } from './PipelineCard';
|
||||
import { SeriesCard } from './SeriesCard';
|
||||
import { api } from "~/shared/lib/api";
|
||||
import { PipelineCard } from "./PipelineCard";
|
||||
import { SeriesCard } from "./SeriesCard";
|
||||
|
||||
interface ReviewColumnProps {
|
||||
items: any[];
|
||||
@@ -10,10 +10,10 @@ interface ReviewColumnProps {
|
||||
|
||||
export function ReviewColumn({ items, jellyfinUrl, onMutate }: ReviewColumnProps) {
|
||||
// Group by series (movies are standalone)
|
||||
const movies = items.filter((i: any) => i.type === 'Movie');
|
||||
const movies = items.filter((i: any) => i.type === "Movie");
|
||||
const seriesMap = new Map<string, { name: string; key: string; jellyfinId: string | null; episodes: any[] }>();
|
||||
|
||||
for (const item of items.filter((i: any) => i.type === 'Episode')) {
|
||||
for (const item of items.filter((i: any) => i.type === "Episode")) {
|
||||
const key = item.series_jellyfin_id ?? item.series_name;
|
||||
if (!seriesMap.has(key)) {
|
||||
seriesMap.set(key, { name: item.series_name, key, jellyfinId: item.series_jellyfin_id, episodes: [] });
|
||||
@@ -28,11 +28,11 @@ export function ReviewColumn({ items, jellyfinUrl, onMutate }: ReviewColumnProps
|
||||
|
||||
// Interleave movies and series, sorted by confidence (high first)
|
||||
const allItems = [
|
||||
...movies.map((m: any) => ({ type: 'movie' as const, item: m, sortKey: m.confidence === 'high' ? 0 : 1 })),
|
||||
...[...seriesMap.values()].map(s => ({
|
||||
type: 'series' as const,
|
||||
...movies.map((m: any) => ({ type: "movie" as const, item: m, sortKey: m.confidence === "high" ? 0 : 1 })),
|
||||
...[...seriesMap.values()].map((s) => ({
|
||||
type: "series" as const,
|
||||
item: s,
|
||||
sortKey: s.episodes.every((e: any) => e.confidence === 'high') ? 0 : 1,
|
||||
sortKey: s.episodes.every((e: any) => e.confidence === "high") ? 0 : 1,
|
||||
})),
|
||||
].sort((a, b) => a.sortKey - b.sortKey);
|
||||
|
||||
@@ -49,7 +49,7 @@ export function ReviewColumn({ items, jellyfinUrl, onMutate }: ReviewColumnProps
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
||||
{allItems.map((entry) => {
|
||||
if (entry.type === 'movie') {
|
||||
if (entry.type === "movie") {
|
||||
return (
|
||||
<PipelineCard
|
||||
key={entry.item.id}
|
||||
@@ -77,9 +77,7 @@ export function ReviewColumn({ items, jellyfinUrl, onMutate }: ReviewColumnProps
|
||||
);
|
||||
}
|
||||
})}
|
||||
{allItems.length === 0 && (
|
||||
<p className="text-sm text-gray-400 text-center py-8">No items to review</p>
|
||||
)}
|
||||
{allItems.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No items to review</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Input } from '~/shared/components/ui/input';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/shared/components/ui/button";
|
||||
import { Input } from "~/shared/components/ui/input";
|
||||
import { api } from "~/shared/lib/api";
|
||||
|
||||
interface ScheduleControlsProps {
|
||||
scheduler: {
|
||||
@@ -18,13 +18,13 @@ export function ScheduleControls({ scheduler, onUpdate }: ScheduleControlsProps)
|
||||
const [state, setState] = useState(scheduler);
|
||||
|
||||
const save = async () => {
|
||||
await api.patch('/api/execute/scheduler', state);
|
||||
await api.patch("/api/execute/scheduler", state);
|
||||
onUpdate();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const startAll = async () => {
|
||||
await api.post('/api/execute/start');
|
||||
await api.post("/api/execute/start");
|
||||
onUpdate();
|
||||
};
|
||||
|
||||
@@ -33,10 +33,7 @@ export function ScheduleControls({ scheduler, onUpdate }: ScheduleControlsProps)
|
||||
<Button variant="primary" size="sm" onClick={startAll}>
|
||||
Start queue
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 cursor-pointer"
|
||||
>
|
||||
<button onClick={() => setOpen(!open)} className="text-xs text-gray-500 hover:text-gray-700 cursor-pointer">
|
||||
Schedule settings
|
||||
</button>
|
||||
|
||||
@@ -49,7 +46,7 @@ export function ScheduleControls({ scheduler, onUpdate }: ScheduleControlsProps)
|
||||
type="number"
|
||||
min={0}
|
||||
value={state.job_sleep_seconds}
|
||||
onChange={(e) => setState({ ...state, job_sleep_seconds: parseInt(e.target.value) || 0 })}
|
||||
onChange={(e) => setState({ ...state, job_sleep_seconds: parseInt(e.target.value, 10) || 0 })}
|
||||
className="mb-3"
|
||||
/>
|
||||
|
||||
@@ -80,7 +77,9 @@ export function ScheduleControls({ scheduler, onUpdate }: ScheduleControlsProps)
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="primary" size="sm" onClick={save}>Save</Button>
|
||||
<Button variant="primary" size="sm" onClick={save}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { LANG_NAMES } from '~/shared/lib/lang';
|
||||
import { PipelineCard } from './PipelineCard';
|
||||
import { useState } from "react";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import { LANG_NAMES } from "~/shared/lib/lang";
|
||||
import { PipelineCard } from "./PipelineCard";
|
||||
|
||||
interface SeriesCardProps {
|
||||
seriesKey: string;
|
||||
@@ -13,10 +13,18 @@ interface SeriesCardProps {
|
||||
onApproveUpTo?: () => void;
|
||||
}
|
||||
|
||||
export function SeriesCard({ seriesKey, seriesName, jellyfinUrl, seriesJellyfinId, episodes, onMutate, onApproveUpTo }: SeriesCardProps) {
|
||||
export function SeriesCard({
|
||||
seriesKey,
|
||||
seriesName,
|
||||
jellyfinUrl,
|
||||
seriesJellyfinId,
|
||||
episodes,
|
||||
onMutate,
|
||||
onApproveUpTo,
|
||||
}: SeriesCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const seriesLang = episodes[0]?.original_language ?? '';
|
||||
const seriesLang = episodes[0]?.original_language ?? "";
|
||||
|
||||
const setSeriesLanguage = async (lang: string) => {
|
||||
await api.patch(`/api/review/series/${encodeURIComponent(seriesKey)}/language`, { language: lang });
|
||||
@@ -28,12 +36,11 @@ export function SeriesCard({ seriesKey, seriesName, jellyfinUrl, seriesJellyfinI
|
||||
onMutate();
|
||||
};
|
||||
|
||||
const highCount = episodes.filter((e: any) => e.confidence === 'high').length;
|
||||
const lowCount = episodes.filter((e: any) => e.confidence === 'low').length;
|
||||
const highCount = episodes.filter((e: any) => e.confidence === "high").length;
|
||||
const lowCount = episodes.filter((e: any) => e.confidence === "low").length;
|
||||
|
||||
const jellyfinLink = jellyfinUrl && seriesJellyfinId
|
||||
? `${jellyfinUrl}/web/index.html#!/details?id=${seriesJellyfinId}`
|
||||
: null;
|
||||
const jellyfinLink =
|
||||
jellyfinUrl && seriesJellyfinId ? `${jellyfinUrl}/web/index.html#!/details?id=${seriesJellyfinId}` : null;
|
||||
|
||||
return (
|
||||
<div className="group rounded-lg border bg-white overflow-hidden">
|
||||
@@ -42,7 +49,7 @@ export function SeriesCard({ seriesKey, seriesName, jellyfinUrl, seriesJellyfinI
|
||||
className="flex items-center gap-2 px-3 pt-3 pb-1 cursor-pointer hover:bg-gray-50 rounded-t-lg"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span className="text-xs text-gray-400 shrink-0">{expanded ? '▼' : '▶'}</span>
|
||||
<span className="text-xs text-gray-400 shrink-0">{expanded ? "▼" : "▶"}</span>
|
||||
{jellyfinLink ? (
|
||||
<a
|
||||
href={jellyfinLink}
|
||||
@@ -67,15 +74,23 @@ export function SeriesCard({ seriesKey, seriesName, jellyfinUrl, seriesJellyfinI
|
||||
<select
|
||||
className="h-6 text-xs border border-gray-300 rounded px-1 bg-white shrink-0"
|
||||
value={seriesLang}
|
||||
onChange={(e) => { e.stopPropagation(); setSeriesLanguage(e.target.value); }}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
setSeriesLanguage(e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">unknown</option>
|
||||
{Object.entries(LANG_NAMES).map(([code, name]) => (
|
||||
<option key={code} value={code}>{name}</option>
|
||||
<option key={code} value={code}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); approveSeries(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
approveSeries();
|
||||
}}
|
||||
className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 cursor-pointer whitespace-nowrap shrink-0"
|
||||
>
|
||||
Approve all
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useParams } from '@tanstack/react-router';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
import { Alert } from '~/shared/components/ui/alert';
|
||||
import { Select } from '~/shared/components/ui/select';
|
||||
import { langName, LANG_NAMES } from '~/shared/lib/lang';
|
||||
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '~/shared/lib/types';
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert } from "~/shared/components/ui/alert";
|
||||
import { Badge } from "~/shared/components/ui/badge";
|
||||
import { Button } from "~/shared/components/ui/button";
|
||||
import { Select } from "~/shared/components/ui/select";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import { LANG_NAMES, langName } from "~/shared/lib/lang";
|
||||
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from "~/shared/lib/types";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DetailData {
|
||||
item: MediaItem; streams: MediaStream[];
|
||||
plan: ReviewPlan | null; decisions: StreamDecision[];
|
||||
item: MediaItem;
|
||||
streams: MediaStream[];
|
||||
plan: ReviewPlan | null;
|
||||
decisions: StreamDecision[];
|
||||
command: string | null;
|
||||
}
|
||||
|
||||
@@ -28,15 +30,15 @@ function formatBytes(bytes: number): string {
|
||||
function effectiveLabel(s: MediaStream, dec: StreamDecision | undefined): string {
|
||||
if (dec?.custom_title) return dec.custom_title;
|
||||
if (s.title) return s.title;
|
||||
if (s.type === 'Audio' && s.channels) return `${s.channels}ch ${s.channel_layout ?? ''}`.trim();
|
||||
return s.language ? langName(s.language) : '';
|
||||
if (s.type === "Audio" && s.channels) return `${s.channels}ch ${s.channel_layout ?? ""}`.trim();
|
||||
return s.language ? langName(s.language) : "";
|
||||
}
|
||||
|
||||
// ─── Stream table ─────────────────────────────────────────────────────────────
|
||||
|
||||
const STREAM_SECTIONS = [
|
||||
{ type: 'Video', label: 'Video' },
|
||||
{ type: 'Audio', label: 'Audio' },
|
||||
{ type: "Video", label: "Video" },
|
||||
{ type: "Audio", label: "Audio" },
|
||||
];
|
||||
|
||||
const TYPE_ORDER: Record<string, number> = { Video: 0, Audio: 1, Subtitle: 2, Data: 3, EmbeddedImage: 4 };
|
||||
@@ -44,10 +46,10 @@ const TYPE_ORDER: Record<string, number> = { Video: 0, Audio: 1, Subtitle: 2, Da
|
||||
/** Compute per-type output indices for kept streams (e.g. a:0, a:1). */
|
||||
function computeOutIdx(streams: MediaStream[], decisions: StreamDecision[]): Map<number, string> {
|
||||
const mappedKept = streams
|
||||
.filter((s) => ['Video', 'Audio'].includes(s.type))
|
||||
.filter((s) => ["Video", "Audio"].includes(s.type))
|
||||
.filter((s) => {
|
||||
const action = decisions.find((d) => d.stream_id === s.id)?.action;
|
||||
return action === 'keep';
|
||||
return action === "keep";
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const ta = TYPE_ORDER[a.type] ?? 9;
|
||||
@@ -60,7 +62,7 @@ function computeOutIdx(streams: MediaStream[], decisions: StreamDecision[]): Map
|
||||
const m = new Map<number, string>();
|
||||
const typeCounts: Record<string, number> = {};
|
||||
for (const s of mappedKept) {
|
||||
const prefix = s.type === 'Video' ? 'v' : 'a';
|
||||
const prefix = s.type === "Video" ? "v" : "a";
|
||||
const idx = typeCounts[s.type] ?? 0;
|
||||
m.set(s.id, `${prefix}:${idx}`);
|
||||
typeCounts[s.type] = idx + 1;
|
||||
@@ -68,14 +70,19 @@ function computeOutIdx(streams: MediaStream[], decisions: StreamDecision[]): Map
|
||||
return m;
|
||||
}
|
||||
|
||||
interface StreamTableProps { data: DetailData; onUpdate: (d: DetailData) => void; }
|
||||
interface StreamTableProps {
|
||||
data: DetailData;
|
||||
onUpdate: (d: DetailData) => void;
|
||||
}
|
||||
|
||||
function StreamTable({ data, onUpdate }: StreamTableProps) {
|
||||
const { item, streams, plan, decisions } = data;
|
||||
const outIdx = computeOutIdx(streams, decisions);
|
||||
|
||||
const toggleStream = async (streamId: number, currentAction: 'keep' | 'remove') => {
|
||||
const d = await api.patch<DetailData>(`/api/review/${item.id}/stream/${streamId}`, { action: currentAction === 'keep' ? 'remove' : 'keep' });
|
||||
const toggleStream = async (streamId: number, currentAction: "keep" | "remove") => {
|
||||
const d = await api.patch<DetailData>(`/api/review/${item.id}/stream/${streamId}`, {
|
||||
action: currentAction === "keep" ? "remove" : "keep",
|
||||
});
|
||||
onUpdate(d);
|
||||
};
|
||||
|
||||
@@ -85,11 +92,17 @@ function StreamTable({ data, onUpdate }: StreamTableProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0"><table className="w-full border-collapse text-[0.79rem] mt-1">
|
||||
<div className="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||
<table className="w-full border-collapse text-[0.79rem] mt-1">
|
||||
<thead>
|
||||
<tr>
|
||||
{['Out', 'Codec', 'Language', 'Title / Info', 'Flags', 'Action'].map((h) => (
|
||||
<th key={h} className="text-left text-[0.67rem] uppercase tracking-[0.05em] text-gray-500 py-1 px-2 border-b border-gray-200">{h}</th>
|
||||
{["Out", "Codec", "Language", "Title / Info", "Flags", "Action"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="text-left text-[0.67rem] uppercase tracking-[0.05em] text-gray-500 py-1 px-2 border-b border-gray-200"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -99,44 +112,42 @@ function StreamTable({ data, onUpdate }: StreamTableProps) {
|
||||
if (group.length === 0) return [];
|
||||
return [
|
||||
<tr key={`hdr-${type}`}>
|
||||
<td colSpan={6} className="text-[0.67rem] font-bold uppercase tracking-[0.06em] text-gray-500 bg-gray-50 py-0.5 px-2 border-b border-gray-100">
|
||||
<td
|
||||
colSpan={6}
|
||||
className="text-[0.67rem] font-bold uppercase tracking-[0.06em] text-gray-500 bg-gray-50 py-0.5 px-2 border-b border-gray-100"
|
||||
>
|
||||
{label}
|
||||
</td>
|
||||
</tr>,
|
||||
...group.map((s) => {
|
||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||
const action = dec?.action ?? 'keep';
|
||||
const isAudio = s.type === 'Audio';
|
||||
const action = dec?.action ?? "keep";
|
||||
const isAudio = s.type === "Audio";
|
||||
|
||||
const outputNum = outIdx.get(s.id);
|
||||
const lbl = effectiveLabel(s, dec);
|
||||
const origTitle = s.title;
|
||||
const lang = langName(s.language);
|
||||
const isEditable = plan?.status === 'pending' && isAudio;
|
||||
const rowBg = action === 'keep' ? 'bg-green-50' : 'bg-red-50';
|
||||
const isEditable = plan?.status === "pending" && isAudio;
|
||||
const rowBg = action === "keep" ? "bg-green-50" : "bg-red-50";
|
||||
|
||||
return (
|
||||
<tr key={s.id} className={rowBg}>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">
|
||||
{outputNum !== undefined ? outputNum : <span className="text-gray-400">—</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.codec ?? '—'}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.codec ?? "—"}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
{isAudio ? (
|
||||
<>{lang} {s.language ? <span className="text-gray-400 font-mono text-xs">({s.language})</span> : null}</>
|
||||
<>
|
||||
{lang} {s.language ? <span className="text-gray-400 font-mono text-xs">({s.language})</span> : null}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
{isEditable ? (
|
||||
<TitleInput
|
||||
value={lbl}
|
||||
onCommit={(v) => updateTitle(s.id, v)}
|
||||
/>
|
||||
) : (
|
||||
<span>{lbl || '—'}</span>
|
||||
)}
|
||||
{isEditable ? <TitleInput value={lbl} onCommit={(v) => updateTitle(s.id, v)} /> : <span>{lbl || "—"}</span>}
|
||||
{isEditable && origTitle && origTitle !== lbl && (
|
||||
<div className="text-gray-400 text-[0.7rem] mt-0.5">orig: {origTitle}</div>
|
||||
)}
|
||||
@@ -149,16 +160,16 @@ function StreamTable({ data, onUpdate }: StreamTableProps) {
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
{plan?.status === 'pending' && isAudio ? (
|
||||
{plan?.status === "pending" && isAudio ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleStream(s.id, action)}
|
||||
className={`border-0 rounded px-2 py-0.5 text-[0.72rem] font-semibold cursor-pointer min-w-[4.5rem] ${action === 'keep' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'}`}
|
||||
className={`border-0 rounded px-2 py-0.5 text-[0.72rem] font-semibold cursor-pointer min-w-[4.5rem] ${action === "keep" ? "bg-green-600 text-white" : "bg-red-600 text-white"}`}
|
||||
>
|
||||
{action === 'keep' ? '✓ Keep' : '✗ Remove'}
|
||||
{action === "keep" ? "✓ Keep" : "✗ Remove"}
|
||||
</button>
|
||||
) : (
|
||||
<Badge variant={action === 'keep' ? 'keep' : 'remove'}>{action}</Badge>
|
||||
<Badge variant={action === "keep" ? "keep" : "remove"}>{action}</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -167,20 +178,27 @@ function StreamTable({ data, onUpdate }: StreamTableProps) {
|
||||
];
|
||||
})}
|
||||
</tbody>
|
||||
</table></div>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
|
||||
const [localVal, setLocalVal] = useState(value);
|
||||
useEffect(() => { setLocalVal(value); }, [value]);
|
||||
useEffect(() => {
|
||||
setLocalVal(value);
|
||||
}, [value]);
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={localVal}
|
||||
onChange={(e) => setLocalVal(e.target.value)}
|
||||
onBlur={(e) => { if (e.target.value !== value) onCommit(e.target.value); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
|
||||
onBlur={(e) => {
|
||||
if (e.target.value !== value) onCommit(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
}}
|
||||
placeholder="—"
|
||||
className="border border-transparent bg-transparent w-full text-[inherit] font-[inherit] text-inherit px-[0.2em] py-[0.05em] rounded outline-none hover:border-gray-300 hover:bg-gray-50 focus:border-blue-300 focus:bg-white min-w-16"
|
||||
/>
|
||||
@@ -190,40 +208,67 @@ function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string)
|
||||
// ─── Detail page ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function AudioDetailPage() {
|
||||
const { id } = useParams({ from: '/review/audio/$id' });
|
||||
const { id } = useParams({ from: "/review/audio/$id" });
|
||||
const [data, setData] = useState<DetailData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rescanning, setRescanning] = useState(false);
|
||||
|
||||
const load = () => api.get<DetailData>(`/api/review/${id}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
|
||||
useEffect(() => { load(); }, [id]);
|
||||
const load = () =>
|
||||
api
|
||||
.get<DetailData>(`/api/review/${id}`)
|
||||
.then((d) => {
|
||||
setData(d);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const setLanguage = async (lang: string) => {
|
||||
const d = await api.patch<DetailData>(`/api/review/${id}/language`, { language: lang || null });
|
||||
setData(d);
|
||||
};
|
||||
|
||||
const approve = async () => { await api.post(`/api/review/${id}/approve`); load(); };
|
||||
const unapprove = async () => { await api.post(`/api/review/${id}/unapprove`); load(); };
|
||||
const skip = async () => { await api.post(`/api/review/${id}/skip`); load(); };
|
||||
const unskip = async () => { await api.post(`/api/review/${id}/unskip`); load(); };
|
||||
const approve = async () => {
|
||||
await api.post(`/api/review/${id}/approve`);
|
||||
load();
|
||||
};
|
||||
const unapprove = async () => {
|
||||
await api.post(`/api/review/${id}/unapprove`);
|
||||
load();
|
||||
};
|
||||
const skip = async () => {
|
||||
await api.post(`/api/review/${id}/skip`);
|
||||
load();
|
||||
};
|
||||
const unskip = async () => {
|
||||
await api.post(`/api/review/${id}/unskip`);
|
||||
load();
|
||||
};
|
||||
const rescan = async () => {
|
||||
setRescanning(true);
|
||||
try { const d = await api.post<DetailData>(`/api/review/${id}/rescan`); setData(d); }
|
||||
finally { setRescanning(false); }
|
||||
try {
|
||||
const d = await api.post<DetailData>(`/api/review/${id}/rescan`);
|
||||
setData(d);
|
||||
} finally {
|
||||
setRescanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
||||
if (!data) return <Alert variant="error">Item not found.</Alert>;
|
||||
|
||||
const { item, plan, command } = data;
|
||||
const statusKey = plan?.is_noop ? 'noop' : (plan?.status ?? 'pending');
|
||||
const statusKey = plan?.is_noop ? "noop" : (plan?.status ?? "pending");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h1 className="text-xl font-bold m-0">
|
||||
<Link to="/review/audio" className="font-normal mr-2 no-underline text-gray-500 hover:text-gray-700">← Audio</Link>
|
||||
<Link to="/review/audio" className="font-normal mr-2 no-underline text-gray-500 hover:text-gray-700">
|
||||
← Audio
|
||||
</Link>
|
||||
{item.name}
|
||||
</h1>
|
||||
</div>
|
||||
@@ -232,12 +277,17 @@ export function AudioDetailPage() {
|
||||
{/* Meta */}
|
||||
<dl className="flex flex-wrap gap-5 mb-3 text-[0.82rem]">
|
||||
{[
|
||||
{ label: 'Type', value: item.type },
|
||||
...(item.series_name ? [{ label: 'Series', value: item.series_name }] : []),
|
||||
...(item.year ? [{ label: 'Year', value: String(item.year) }] : []),
|
||||
{ label: 'Container', value: item.container ?? '—' },
|
||||
{ label: 'File size', value: item.file_size ? formatBytes(item.file_size) : '—' },
|
||||
{ label: 'Status', value: <Badge variant={statusKey as 'noop' | 'pending' | 'approved' | 'skipped' | 'done' | 'error'}>{statusKey}</Badge> },
|
||||
{ label: "Type", value: item.type },
|
||||
...(item.series_name ? [{ label: "Series", value: item.series_name }] : []),
|
||||
...(item.year ? [{ label: "Year", value: String(item.year) }] : []),
|
||||
{ label: "Container", value: item.container ?? "—" },
|
||||
{ label: "File size", value: item.file_size ? formatBytes(item.file_size) : "—" },
|
||||
{
|
||||
label: "Status",
|
||||
value: (
|
||||
<Badge variant={statusKey as "noop" | "pending" | "approved" | "skipped" | "done" | "error"}>{statusKey}</Badge>
|
||||
),
|
||||
},
|
||||
].map((entry, i) => (
|
||||
<div key={i}>
|
||||
<dt className="text-gray-500 text-[0.68rem] uppercase tracking-[0.05em] mb-0.5">{entry.label}</dt>
|
||||
@@ -249,7 +299,11 @@ export function AudioDetailPage() {
|
||||
<div className="font-mono text-gray-400 text-[0.78rem] mb-4 break-all">{item.file_path}</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{plan?.notes && <Alert variant="warning" className="mb-3">{plan.notes}</Alert>}
|
||||
{plan?.notes && (
|
||||
<Alert variant="warning" className="mb-3">
|
||||
{plan.notes}
|
||||
</Alert>
|
||||
)}
|
||||
{item.needs_review && !item.original_language && (
|
||||
<Alert variant="warning" className="mb-3">
|
||||
Original language unknown — audio tracks will NOT be filtered until you set it below.
|
||||
@@ -259,10 +313,16 @@ export function AudioDetailPage() {
|
||||
{/* Language override */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<label className="text-[0.85rem] m-0">Original language:</label>
|
||||
<Select value={item.original_language ?? ''} onChange={(e) => setLanguage(e.target.value)} className="text-[0.79rem] py-0.5 px-1.5 w-auto">
|
||||
<Select
|
||||
value={item.original_language ?? ""}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
className="text-[0.79rem] py-0.5 px-1.5 w-auto"
|
||||
>
|
||||
<option value="">— Unknown —</option>
|
||||
{Object.entries(LANG_NAMES).map(([code, name]) => (
|
||||
<option key={code} value={code}>{name} ({code})</option>
|
||||
<option key={code} value={code}>
|
||||
{name} ({code})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{item.orig_lang_source && <Badge>{item.orig_lang_source}</Badge>}
|
||||
@@ -285,33 +345,43 @@ export function AudioDetailPage() {
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{plan?.status === 'pending' && !plan.is_noop && (
|
||||
{plan?.status === "pending" && !plan.is_noop && (
|
||||
<div className="flex gap-2 mt-6">
|
||||
<Button onClick={approve}>✓ Approve</Button>
|
||||
<Button variant="secondary" onClick={skip}>Skip</Button>
|
||||
<Button variant="secondary" onClick={skip}>
|
||||
Skip
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{plan?.status === 'approved' && (
|
||||
{plan?.status === "approved" && (
|
||||
<div className="mt-6">
|
||||
<Button variant="secondary" onClick={unapprove}>Unapprove</Button>
|
||||
<Button variant="secondary" onClick={unapprove}>
|
||||
Unapprove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{plan?.status === 'skipped' && (
|
||||
{plan?.status === "skipped" && (
|
||||
<div className="mt-6">
|
||||
<Button variant="secondary" onClick={unskip}>Unskip</Button>
|
||||
<Button variant="secondary" onClick={unskip}>
|
||||
Unskip
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{plan?.is_noop ? (
|
||||
<Alert variant="success" className="mt-4">Audio is already clean — no audio changes needed.</Alert>
|
||||
<Alert variant="success" className="mt-4">
|
||||
Audio is already clean — no audio changes needed.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{/* Refresh */}
|
||||
<div className="flex items-center gap-3 mt-6 pt-3 border-t border-gray-200">
|
||||
<Button variant="secondary" size="sm" onClick={rescan} disabled={rescanning}>
|
||||
{rescanning ? '↻ Refreshing…' : '↻ Refresh from Jellyfin'}
|
||||
{rescanning ? "↻ Refreshing…" : "↻ Refresh from Jellyfin"}
|
||||
</Button>
|
||||
<span className="text-gray-400 text-[0.75rem]">
|
||||
{rescanning ? 'Triggering Jellyfin metadata probe and waiting for completion…' : 'Triggers a metadata re-probe in Jellyfin, then re-fetches stream data'}
|
||||
{rescanning
|
||||
? "Triggering Jellyfin metadata probe and waiting for completion…"
|
||||
: "Triggers a metadata re-probe in Jellyfin, then re-fetches stream data"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
import { FilterTabs } from '~/shared/components/ui/filter-tabs';
|
||||
import { langName } from '~/shared/lib/lang';
|
||||
import type { MediaItem, ReviewPlan } from '~/shared/lib/types';
|
||||
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
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";
|
||||
import type { MediaItem, ReviewPlan } from "~/shared/lib/types";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface MovieRow { item: MediaItem; plan: ReviewPlan | null; removeCount: number; keepCount: number; }
|
||||
interface MovieRow {
|
||||
item: MediaItem;
|
||||
plan: ReviewPlan | null;
|
||||
removeCount: number;
|
||||
keepCount: number;
|
||||
}
|
||||
|
||||
interface SeriesGroup {
|
||||
series_key: string; series_name: string; original_language: string | null;
|
||||
season_count: number; episode_count: number;
|
||||
noop_count: number; needs_action_count: number; approved_count: number;
|
||||
skipped_count: number; done_count: number; error_count: number; manual_count: number;
|
||||
series_key: string;
|
||||
series_name: string;
|
||||
original_language: string | null;
|
||||
season_count: number;
|
||||
episode_count: number;
|
||||
noop_count: number;
|
||||
needs_action_count: number;
|
||||
approved_count: number;
|
||||
skipped_count: number;
|
||||
done_count: number;
|
||||
error_count: number;
|
||||
manual_count: number;
|
||||
}
|
||||
|
||||
interface ReviewListData {
|
||||
@@ -28,10 +41,14 @@ interface ReviewListData {
|
||||
// ─── Filter tabs ──────────────────────────────────────────────────────────────
|
||||
|
||||
const FILTER_TABS = [
|
||||
{ key: 'all', label: 'All' }, { key: 'needs_action', label: 'Needs Action' },
|
||||
{ key: 'noop', label: 'No Change' }, { key: 'manual', label: 'Manual Review' },
|
||||
{ key: 'approved', label: 'Approved' }, { key: 'skipped', label: 'Skipped' },
|
||||
{ key: 'done', label: 'Done' }, { key: 'error', label: 'Error' },
|
||||
{ key: "all", label: "All" },
|
||||
{ key: "needs_action", label: "Needs Action" },
|
||||
{ key: "noop", label: "No Change" },
|
||||
{ key: "manual", label: "Manual Review" },
|
||||
{ key: "approved", label: "Approved" },
|
||||
{ key: "skipped", label: "Skipped" },
|
||||
{ key: "done", label: "Done" },
|
||||
{ key: "error", label: "Error" },
|
||||
];
|
||||
|
||||
// ─── Status pills ─────────────────────────────────────────────────────────────
|
||||
@@ -39,13 +56,41 @@ const FILTER_TABS = [
|
||||
function StatusPills({ g }: { g: SeriesGroup }) {
|
||||
return (
|
||||
<span className="inline-flex flex-wrap gap-1 items-center">
|
||||
{g.noop_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">{g.noop_count} ok</span>}
|
||||
{g.needs_action_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-800">{g.needs_action_count} action</span>}
|
||||
{g.approved_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-green-100 text-green-800">{g.approved_count} approved</span>}
|
||||
{g.done_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-cyan-100 text-cyan-800">{g.done_count} done</span>}
|
||||
{g.error_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-800">{g.error_count} err</span>}
|
||||
{g.skipped_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">{g.skipped_count} skip</span>}
|
||||
{g.manual_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-orange-100 text-orange-800">{g.manual_count} manual</span>}
|
||||
{g.noop_count > 0 && (
|
||||
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">
|
||||
{g.noop_count} ok
|
||||
</span>
|
||||
)}
|
||||
{g.needs_action_count > 0 && (
|
||||
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-800">
|
||||
{g.needs_action_count} action
|
||||
</span>
|
||||
)}
|
||||
{g.approved_count > 0 && (
|
||||
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-green-100 text-green-800">
|
||||
{g.approved_count} approved
|
||||
</span>
|
||||
)}
|
||||
{g.done_count > 0 && (
|
||||
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-cyan-100 text-cyan-800">
|
||||
{g.done_count} done
|
||||
</span>
|
||||
)}
|
||||
{g.error_count > 0 && (
|
||||
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-800">
|
||||
{g.error_count} err
|
||||
</span>
|
||||
)}
|
||||
{g.skipped_count > 0 && (
|
||||
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">
|
||||
{g.skipped_count} skip
|
||||
</span>
|
||||
)}
|
||||
{g.manual_count > 0 && (
|
||||
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-orange-100 text-orange-800">
|
||||
{g.manual_count} manual
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -59,7 +104,7 @@ const Th = ({ children }: { children: React.ReactNode }) => (
|
||||
);
|
||||
|
||||
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>
|
||||
<td className={`py-1.5 px-2 border-b border-gray-100 align-middle ${className ?? ""}`}>{children}</td>
|
||||
);
|
||||
|
||||
// ─── Series row (collapsible) ─────────────────────────────────────────────────
|
||||
@@ -68,8 +113,19 @@ function SeriesRow({ g }: { g: SeriesGroup }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const urlKey = encodeURIComponent(g.series_key);
|
||||
|
||||
interface EpisodeItem { item: MediaItem; plan: ReviewPlan | null; removeCount: number; }
|
||||
interface SeasonGroup { season: number | null; episodes: EpisodeItem[]; noopCount: number; actionCount: number; approvedCount: number; doneCount: number; }
|
||||
interface EpisodeItem {
|
||||
item: MediaItem;
|
||||
plan: ReviewPlan | null;
|
||||
removeCount: number;
|
||||
}
|
||||
interface SeasonGroup {
|
||||
season: number | null;
|
||||
episodes: EpisodeItem[];
|
||||
noopCount: number;
|
||||
actionCount: number;
|
||||
approvedCount: number;
|
||||
doneCount: number;
|
||||
}
|
||||
|
||||
const [seasons, setSeasons] = useState<SeasonGroup[] | null>(null);
|
||||
|
||||
@@ -93,25 +149,30 @@ function SeriesRow({ g }: { g: SeriesGroup }) {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const statusKey = (plan: ReviewPlan | null) => plan?.is_noop ? 'noop' : (plan?.status ?? 'pending');
|
||||
const statusKey = (plan: ReviewPlan | null) => (plan?.is_noop ? "noop" : (plan?.status ?? "pending"));
|
||||
|
||||
return (
|
||||
<tbody>
|
||||
<tr
|
||||
className="cursor-pointer hover:bg-gray-50"
|
||||
onClick={toggle}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<Td>
|
||||
<StatusPills g={g} />
|
||||
</Td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
|
||||
{g.needs_action_count > 0 && (
|
||||
<Button size="xs" onClick={approveAll}>Approve all</Button>
|
||||
<Button size="xs" onClick={approveAll}>
|
||||
Approve all
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -123,13 +184,32 @@ function SeriesRow({ g }: { g: SeriesGroup }) {
|
||||
{seasons.map((s) => (
|
||||
<>
|
||||
<tr key={`season-${s.season}`} className="bg-gray-50">
|
||||
<td colSpan={4} 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 ?? '?'}
|
||||
<td
|
||||
colSpan={4}
|
||||
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.noopCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-gray-200 text-gray-600 text-[0.7rem]">{s.noopCount} ok</span>}
|
||||
{s.actionCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-red-100 text-red-800 text-[0.7rem]">{s.actionCount} action</span>}
|
||||
{s.approvedCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-800 text-[0.7rem]">{s.approvedCount} approved</span>}
|
||||
{s.doneCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-cyan-100 text-cyan-800 text-[0.7rem]">{s.doneCount} done</span>}
|
||||
{s.noopCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-gray-200 text-gray-600 text-[0.7rem]">
|
||||
{s.noopCount} ok
|
||||
</span>
|
||||
)}
|
||||
{s.actionCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-red-100 text-red-800 text-[0.7rem]">
|
||||
{s.actionCount} action
|
||||
</span>
|
||||
)}
|
||||
{s.approvedCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-800 text-[0.7rem]">
|
||||
{s.approvedCount} approved
|
||||
</span>
|
||||
)}
|
||||
{s.doneCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-cyan-100 text-cyan-800 text-[0.7rem]">
|
||||
{s.doneCount} done
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{s.actionCount > 0 && (
|
||||
<Button size="xs" variant="secondary" className="ml-3" onClick={(e) => approveSeason(e, s.season)}>
|
||||
@@ -141,27 +221,32 @@ function SeriesRow({ g }: { g: SeriesGroup }) {
|
||||
{s.episodes.map(({ item, plan, removeCount }) => (
|
||||
<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>
|
||||
{' '}
|
||||
<span className="text-gray-400 font-mono text-xs">
|
||||
E{String(item.episode_number ?? 0).padStart(2, "0")}
|
||||
</span>{" "}
|
||||
<span className="truncate inline-block max-w-xs align-bottom">{item.name}</span>
|
||||
</td>
|
||||
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem]">
|
||||
{removeCount > 0 ? <Badge variant="remove">−{removeCount}</Badge> : <span className="text-gray-400">—</span>}
|
||||
{removeCount > 0 ? (
|
||||
<Badge variant="remove">−{removeCount}</Badge>
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem]">
|
||||
<Badge variant={statusKey(plan) as 'noop' | 'pending' | 'approved' | 'skipped' | 'done' | 'error'}>{plan?.is_noop ? 'ok' : (plan?.status ?? 'pending')}</Badge>
|
||||
<Badge variant={statusKey(plan) as "noop" | "pending" | "approved" | "skipped" | "done" | "error"}>
|
||||
{plan?.is_noop ? "ok" : (plan?.status ?? "pending")}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-1 px-2 border-b border-gray-100 whitespace-nowrap flex gap-1 items-center">
|
||||
{plan?.status === 'pending' && !plan.is_noop && (
|
||||
<ApproveBtn itemId={item.id} size="xs" />
|
||||
)}
|
||||
{plan?.status === 'pending' && (
|
||||
<SkipBtn itemId={item.id} size="xs" />
|
||||
)}
|
||||
{plan?.status === 'skipped' && (
|
||||
<UnskipBtn itemId={item.id} size="xs" />
|
||||
)}
|
||||
<Link to="/review/audio/$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">
|
||||
{plan?.status === "pending" && !plan.is_noop && <ApproveBtn itemId={item.id} size="xs" />}
|
||||
{plan?.status === "pending" && <SkipBtn itemId={item.id} size="xs" />}
|
||||
{plan?.status === "skipped" && <UnskipBtn itemId={item.id} size="xs" />}
|
||||
<Link
|
||||
to="/review/audio/$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>
|
||||
@@ -180,19 +265,40 @@ function SeriesRow({ g }: { g: SeriesGroup }) {
|
||||
|
||||
// ─── Action buttons ───────────────────────────────────────────────────────────
|
||||
|
||||
function ApproveBtn({ itemId, size }: { itemId: number; size?: 'xs' | 'sm' }) {
|
||||
const onClick = async () => { await api.post(`/api/review/${itemId}/approve`); window.location.reload(); };
|
||||
return <Button size={size ?? 'xs'} onClick={onClick}>Approve</Button>;
|
||||
function ApproveBtn({ itemId, size }: { itemId: number; size?: "xs" | "sm" }) {
|
||||
const onClick = async () => {
|
||||
await api.post(`/api/review/${itemId}/approve`);
|
||||
window.location.reload();
|
||||
};
|
||||
return (
|
||||
<Button size={size ?? "xs"} onClick={onClick}>
|
||||
Approve
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SkipBtn({ itemId, size }: { itemId: number; size?: 'xs' | 'sm' }) {
|
||||
const onClick = async () => { await api.post(`/api/review/${itemId}/skip`); window.location.reload(); };
|
||||
return <Button size={size ?? 'xs'} variant="secondary" onClick={onClick}>Skip</Button>;
|
||||
function SkipBtn({ itemId, size }: { itemId: number; size?: "xs" | "sm" }) {
|
||||
const onClick = async () => {
|
||||
await api.post(`/api/review/${itemId}/skip`);
|
||||
window.location.reload();
|
||||
};
|
||||
return (
|
||||
<Button size={size ?? "xs"} variant="secondary" onClick={onClick}>
|
||||
Skip
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function UnskipBtn({ itemId, size }: { itemId: number; size?: 'xs' | 'sm' }) {
|
||||
const onClick = async () => { await api.post(`/api/review/${itemId}/unskip`); window.location.reload(); };
|
||||
return <Button size={size ?? 'xs'} variant="secondary" onClick={onClick}>Unskip</Button>;
|
||||
function UnskipBtn({ itemId, size }: { itemId: number; size?: "xs" | "sm" }) {
|
||||
const onClick = async () => {
|
||||
await api.post(`/api/review/${itemId}/unskip`);
|
||||
window.location.reload();
|
||||
};
|
||||
return (
|
||||
<Button size={size ?? "xs"} variant="secondary" onClick={onClick}>
|
||||
Unskip
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Cache ────────────────────────────────────────────────────────────────────
|
||||
@@ -202,22 +308,31 @@ const cache = new Map<string, ReviewListData>();
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function AudioListPage() {
|
||||
const { filter } = useSearch({ from: '/review/audio/' });
|
||||
const { filter } = useSearch({ from: "/review/audio/" });
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<ReviewListData | null>(cache.get(filter) ?? null);
|
||||
const [loading, setLoading] = useState(!cache.has(filter));
|
||||
|
||||
useEffect(() => {
|
||||
const cached = cache.get(filter);
|
||||
if (cached) { setData(cached); setLoading(false); }
|
||||
else { setLoading(true); }
|
||||
api.get<ReviewListData>(`/api/review?filter=${filter}`)
|
||||
.then((d) => { cache.set(filter, d); setData(d); setLoading(false); })
|
||||
if (cached) {
|
||||
setData(cached);
|
||||
setLoading(false);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
api
|
||||
.get<ReviewListData>(`/api/review?filter=${filter}`)
|
||||
.then((d) => {
|
||||
cache.set(filter, d);
|
||||
setData(d);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, [filter]);
|
||||
|
||||
const approveAll = async () => {
|
||||
await api.post('/api/review/approve-all');
|
||||
await api.post("/api/review/approve-all");
|
||||
cache.clear();
|
||||
window.location.reload();
|
||||
};
|
||||
@@ -227,7 +342,7 @@ export function AudioListPage() {
|
||||
|
||||
const { movies, series, totalCounts } = data;
|
||||
const hasPending = (totalCounts.needs_action ?? 0) > 0;
|
||||
const statusKey = (plan: ReviewPlan | null) => plan?.is_noop ? 'noop' : (plan?.status ?? 'pending');
|
||||
const statusKey = (plan: ReviewPlan | null) => (plan?.is_noop ? "noop" : (plan?.status ?? "pending"));
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -236,8 +351,13 @@ export function AudioListPage() {
|
||||
<div className="border border-gray-200 rounded-lg px-4 py-3 mb-6 flex items-center gap-3 flex-wrap">
|
||||
{hasPending ? (
|
||||
<>
|
||||
<span className="text-sm font-medium">{totalCounts.needs_action} item{totalCounts.needs_action !== 1 ? 's' : ''} need{totalCounts.needs_action === 1 ? 's' : ''} review</span>
|
||||
<Button size="sm" onClick={approveAll}>Approve all pending</Button>
|
||||
<span className="text-sm font-medium">
|
||||
{totalCounts.needs_action} item{totalCounts.needs_action !== 1 ? "s" : ""} need
|
||||
{totalCounts.needs_action === 1 ? "s" : ""} review
|
||||
</span>
|
||||
<Button size="sm" onClick={approveAll}>
|
||||
Approve all pending
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm font-medium">All items reviewed</span>
|
||||
@@ -248,12 +368,10 @@ export function AudioListPage() {
|
||||
tabs={FILTER_TABS}
|
||||
filter={filter}
|
||||
totalCounts={totalCounts}
|
||||
onFilterChange={(key) => navigate({ to: '/review/audio', search: { filter: key } as never })}
|
||||
onFilterChange={(key) => navigate({ to: "/review/audio", search: { filter: key } as never })}
|
||||
/>
|
||||
|
||||
{movies.length === 0 && series.length === 0 && (
|
||||
<p className="text-gray-500">No items match this filter.</p>
|
||||
)}
|
||||
{movies.length === 0 && series.length === 0 && <p className="text-gray-500">No items match this filter.</p>}
|
||||
|
||||
{/* Movies */}
|
||||
{movies.length > 0 && (
|
||||
@@ -263,26 +381,48 @@ export function AudioListPage() {
|
||||
</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>Remove</Th><Th>Status</Th><Th>Actions</Th></tr></thead>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Lang</Th>
|
||||
<Th>Remove</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Actions</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{movies.map(({ item, plan, removeCount }) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<Td>
|
||||
<span className="truncate inline-block max-w-[200px] sm:max-w-[360px]" title={item.name}>{item.name}</span>
|
||||
<span className="truncate inline-block max-w-[200px] sm:max-w-[360px]" title={item.name}>
|
||||
{item.name}
|
||||
</span>
|
||||
{item.year && <span className="text-gray-400 text-[0.72rem]"> ({item.year})</span>}
|
||||
</Td>
|
||||
<Td>
|
||||
{item.needs_review && !item.original_language
|
||||
? <Badge variant="manual">manual</Badge>
|
||||
: <span>{langName(item.original_language)}</span>}
|
||||
{item.needs_review && !item.original_language ? (
|
||||
<Badge variant="manual">manual</Badge>
|
||||
) : (
|
||||
<span>{langName(item.original_language)}</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
{removeCount > 0 ? <Badge variant="remove">−{removeCount}</Badge> : <span className="text-gray-400">—</span>}
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={statusKey(plan) as "noop" | "pending" | "approved" | "skipped" | "done" | "error"}>
|
||||
{plan?.is_noop ? "ok" : (plan?.status ?? "pending")}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>{removeCount > 0 ? <Badge variant="remove">−{removeCount}</Badge> : <span className="text-gray-400">—</span>}</Td>
|
||||
<Td><Badge variant={statusKey(plan) as 'noop' | 'pending' | 'approved' | 'skipped' | 'done' | 'error'}>{plan?.is_noop ? 'ok' : (plan?.status ?? 'pending')}</Badge></Td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap flex gap-1 items-center">
|
||||
{plan?.status === 'pending' && !plan.is_noop && <ApproveBtn itemId={item.id} />}
|
||||
{plan?.status === 'pending' && <SkipBtn itemId={item.id} />}
|
||||
{plan?.status === 'skipped' && <UnskipBtn itemId={item.id} />}
|
||||
<Link to="/review/audio/$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">
|
||||
{plan?.status === "pending" && !plan.is_noop && <ApproveBtn itemId={item.id} />}
|
||||
{plan?.status === "pending" && <SkipBtn itemId={item.id} />}
|
||||
{plan?.status === "skipped" && <UnskipBtn itemId={item.id} />}
|
||||
<Link
|
||||
to="/review/audio/$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>
|
||||
@@ -297,13 +437,26 @@ export function AudioListPage() {
|
||||
{/* TV Series */}
|
||||
{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 ${movies.length > 0 ? 'mt-5' : 'mt-0'}`}>
|
||||
<div
|
||||
className={`flex items-center gap-2 mb-1.5 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-gray-500 ${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">{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><Th>Actions</Th></tr></thead>
|
||||
{series.map((g) => <SeriesRow key={g.series_key} g={g} />)}
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>Series</Th>
|
||||
<Th>Lang</Th>
|
||||
<Th>S</Th>
|
||||
<Th>Ep</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Actions</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
{series.map((g) => (
|
||||
<SeriesRow key={g.series_key} g={g} />
|
||||
))}
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
@@ -312,4 +465,4 @@ export function AudioListPage() {
|
||||
);
|
||||
}
|
||||
|
||||
import type React from 'react';
|
||||
import type React from "react";
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Badge } from "~/shared/components/ui/badge";
|
||||
import { Button } from "~/shared/components/ui/button";
|
||||
import { api } from "~/shared/lib/api";
|
||||
|
||||
interface ScanStatus { running: boolean; progress: { scanned: number; total: number; errors: number }; recentItems: { name: string; type: string; scan_status: string; file_path: string }[]; scanLimit: number | null; }
|
||||
interface LogEntry { name: string; type: string; status: string; file?: string; }
|
||||
interface ScanStatus {
|
||||
running: boolean;
|
||||
progress: { scanned: number; total: number; errors: number };
|
||||
recentItems: { name: string; type: string; scan_status: string; file_path: string }[];
|
||||
scanLimit: number | null;
|
||||
}
|
||||
interface LogEntry {
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
file?: string;
|
||||
}
|
||||
|
||||
// Mutable buffer for SSE data — flushed to React state on an interval
|
||||
interface SseBuf {
|
||||
@@ -20,18 +30,18 @@ interface SseBuf {
|
||||
}
|
||||
|
||||
function freshBuf(): SseBuf {
|
||||
return { scanned: 0, total: 0, errors: 0, currentItem: '', newLogs: [], dirty: false, complete: null, lost: false };
|
||||
return { scanned: 0, total: 0, errors: 0, currentItem: "", newLogs: [], dirty: false, complete: null, lost: false };
|
||||
}
|
||||
|
||||
const FLUSH_MS = 200;
|
||||
|
||||
export function ScanPage() {
|
||||
const [status, setStatus] = useState<ScanStatus | null>(null);
|
||||
const [limit, setLimit] = useState('');
|
||||
const [limit, setLimit] = useState("");
|
||||
const [log, setLog] = useState<LogEntry[]>([]);
|
||||
const [statusLabel, setStatusLabel] = useState('');
|
||||
const [statusLabel, setStatusLabel] = useState("");
|
||||
const [scanComplete, setScanComplete] = useState(false);
|
||||
const [currentItem, setCurrentItem] = useState('');
|
||||
const [currentItem, setCurrentItem] = useState("");
|
||||
const [progressScanned, setProgressScanned] = useState(0);
|
||||
const [progressTotal, setProgressTotal] = useState(0);
|
||||
const [errors, setErrors] = useState(0);
|
||||
@@ -59,19 +69,19 @@ export function ScanPage() {
|
||||
if (b.complete) {
|
||||
const d = b.complete;
|
||||
b.complete = null;
|
||||
setStatusLabel(`Scan complete — ${d.scanned ?? '?'} items, ${d.errors ?? 0} errors`);
|
||||
setStatusLabel(`Scan complete — ${d.scanned ?? "?"} items, ${d.errors ?? 0} errors`);
|
||||
setScanComplete(true);
|
||||
setStatus((prev) => prev ? { ...prev, running: false } : prev);
|
||||
setStatus((prev) => (prev ? { ...prev, running: false } : prev));
|
||||
stopFlushing();
|
||||
}
|
||||
|
||||
if (b.lost) {
|
||||
b.lost = false;
|
||||
setStatusLabel('Scan connection lost — refresh to see current status');
|
||||
setStatus((prev) => prev ? { ...prev, running: false } : prev);
|
||||
setStatusLabel("Scan connection lost — refresh to see current status");
|
||||
setStatus((prev) => (prev ? { ...prev, running: false } : prev));
|
||||
stopFlushing();
|
||||
}
|
||||
}, []);
|
||||
}, [stopFlushing]);
|
||||
|
||||
const startFlushing = useCallback(() => {
|
||||
if (timerRef.current) return;
|
||||
@@ -86,50 +96,57 @@ export function ScanPage() {
|
||||
}, [flush]);
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current); }, []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const load = async () => {
|
||||
const s = await api.get<ScanStatus>('/api/scan');
|
||||
const s = await api.get<ScanStatus>("/api/scan");
|
||||
setStatus(s);
|
||||
setProgressScanned(s.progress.scanned);
|
||||
setProgressTotal(s.progress.total);
|
||||
setErrors(s.progress.errors);
|
||||
setStatusLabel(s.running ? 'Scan in progress…' : 'Scan idle');
|
||||
setStatusLabel(s.running ? "Scan in progress…" : "Scan idle");
|
||||
if (s.scanLimit != null) setLimit(String(s.scanLimit));
|
||||
setLog(s.recentItems.map((i) => ({ name: i.name, type: i.type, status: i.scan_status, file: i.file_path })));
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const connectSse = useCallback(() => {
|
||||
esRef.current?.close();
|
||||
const buf = bufRef.current;
|
||||
const es = new EventSource('/api/scan/events');
|
||||
const es = new EventSource("/api/scan/events");
|
||||
esRef.current = es;
|
||||
|
||||
es.addEventListener('progress', (e) => {
|
||||
es.addEventListener("progress", (e) => {
|
||||
const d = JSON.parse(e.data) as { scanned: number; total: number; errors: number; current_item: string };
|
||||
buf.scanned = d.scanned;
|
||||
buf.total = d.total;
|
||||
buf.errors = d.errors;
|
||||
buf.currentItem = d.current_item ?? '';
|
||||
buf.currentItem = d.current_item ?? "";
|
||||
buf.dirty = true;
|
||||
});
|
||||
|
||||
es.addEventListener('log', (e) => {
|
||||
es.addEventListener("log", (e) => {
|
||||
const d = JSON.parse(e.data) as LogEntry;
|
||||
buf.newLogs.push(d);
|
||||
buf.dirty = true;
|
||||
});
|
||||
|
||||
es.addEventListener('complete', (e) => {
|
||||
const d = JSON.parse(e.data || '{}') as { scanned?: number; errors?: number };
|
||||
es.addEventListener("complete", (e) => {
|
||||
const d = JSON.parse(e.data || "{}") as { scanned?: number; errors?: number };
|
||||
es.close();
|
||||
esRef.current = null;
|
||||
buf.complete = d;
|
||||
});
|
||||
|
||||
es.addEventListener('error', () => {
|
||||
es.addEventListener("error", () => {
|
||||
es.close();
|
||||
esRef.current = null;
|
||||
buf.lost = true;
|
||||
@@ -143,7 +160,11 @@ export function ScanPage() {
|
||||
useEffect(() => {
|
||||
if (!status?.running || esRef.current) return;
|
||||
connectSse();
|
||||
return () => { esRef.current?.close(); esRef.current = null; stopFlushing(); };
|
||||
return () => {
|
||||
esRef.current?.close();
|
||||
esRef.current = null;
|
||||
stopFlushing();
|
||||
};
|
||||
}, [status?.running, connectSse, stopFlushing]);
|
||||
|
||||
const startScan = async () => {
|
||||
@@ -151,26 +172,26 @@ export function ScanPage() {
|
||||
setProgressScanned(0);
|
||||
setProgressTotal(0);
|
||||
setErrors(0);
|
||||
setCurrentItem('');
|
||||
setStatusLabel('Scan in progress…');
|
||||
setCurrentItem("");
|
||||
setStatusLabel("Scan in progress…");
|
||||
setScanComplete(false);
|
||||
setStatus((prev) => prev ? { ...prev, running: true } : prev);
|
||||
setStatus((prev) => (prev ? { ...prev, running: true } : prev));
|
||||
bufRef.current = freshBuf();
|
||||
|
||||
// Connect SSE before starting the scan so no events are missed
|
||||
connectSse();
|
||||
|
||||
const limitNum = limit ? Number(limit) : undefined;
|
||||
await api.post('/api/scan/start', limitNum !== undefined ? { limit: limitNum } : {});
|
||||
await api.post("/api/scan/start", limitNum !== undefined ? { limit: limitNum } : {});
|
||||
};
|
||||
|
||||
const stopScan = async () => {
|
||||
await api.post('/api/scan/stop', {});
|
||||
await api.post("/api/scan/stop", {});
|
||||
esRef.current?.close();
|
||||
esRef.current = null;
|
||||
stopFlushing();
|
||||
setStatus((prev) => prev ? { ...prev, running: false } : prev);
|
||||
setStatusLabel('Scan stopped');
|
||||
setStatus((prev) => (prev ? { ...prev, running: false } : prev));
|
||||
setStatusLabel("Scan stopped");
|
||||
};
|
||||
|
||||
const pct = progressTotal > 0 ? Math.round((progressScanned / progressTotal) * 100) : 0;
|
||||
@@ -182,14 +203,16 @@ export function ScanPage() {
|
||||
|
||||
<div className="border border-gray-200 rounded-lg px-4 py-3 mb-6">
|
||||
<div className="flex items-center flex-wrap gap-2 mb-3">
|
||||
<span className="text-sm font-medium">{statusLabel || (running ? 'Scan in progress…' : 'Scan idle')}</span>
|
||||
<span className="text-sm font-medium">{statusLabel || (running ? "Scan in progress…" : "Scan idle")}</span>
|
||||
{scanComplete && (
|
||||
<Link to="/pipeline" className="text-blue-600 hover:underline text-sm">
|
||||
Review in Pipeline →
|
||||
</Link>
|
||||
)}
|
||||
{running ? (
|
||||
<Button variant="secondary" size="sm" onClick={stopScan}>Stop</Button>
|
||||
<Button variant="secondary" size="sm" onClick={stopScan}>
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1.5 text-xs m-0">
|
||||
@@ -204,7 +227,9 @@ export function ScanPage() {
|
||||
/>
|
||||
items
|
||||
</label>
|
||||
<Button size="sm" onClick={startScan}>Start Scan</Button>
|
||||
<Button size="sm" onClick={startScan}>
|
||||
Start Scan
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{errors > 0 && <Badge variant="error">{errors} error(s)</Badge>}
|
||||
@@ -218,7 +243,10 @@ export function ScanPage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-gray-500 text-xs">
|
||||
<span>{progressScanned}{progressTotal > 0 ? ` / ${progressTotal}` : ''} scanned</span>
|
||||
<span>
|
||||
{progressScanned}
|
||||
{progressTotal > 0 ? ` / ${progressTotal}` : ""} scanned
|
||||
</span>
|
||||
{currentItem && <span className="truncate max-w-xs text-gray-400">{currentItem}</span>}
|
||||
</div>
|
||||
</>
|
||||
@@ -230,20 +258,27 @@ export function ScanPage() {
|
||||
<table className="w-full border-collapse text-[0.82rem]">
|
||||
<thead>
|
||||
<tr>
|
||||
{['Type', 'File', 'Status'].map((h) => (
|
||||
<th key={h} 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">{h}</th>
|
||||
{["Type", "File", "Status"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
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"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{log.map((item, i) => {
|
||||
const fileName = item.file ? item.file.split('/').pop() ?? item.name : item.name;
|
||||
const fileName = item.file ? (item.file.split("/").pop() ?? item.name) : item.name;
|
||||
return (
|
||||
<tr key={i} className="hover:bg-gray-50">
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">{item.type}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100" title={item.file ?? item.name}>{fileName}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100" title={item.file ?? item.name}>
|
||||
{fileName}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
<Badge variant={item.status as 'error' | 'done' | 'pending'}>{item.status}</Badge>
|
||||
<Badge variant={item.status as "error" | "done" | "pending"}>{item.status}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
import { Input } from '~/shared/components/ui/input';
|
||||
import { Select } from '~/shared/components/ui/select';
|
||||
import { LANG_NAMES } from '~/shared/lib/lang';
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "~/shared/components/ui/button";
|
||||
import { Input } from "~/shared/components/ui/input";
|
||||
import { Select } from "~/shared/components/ui/select";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import { LANG_NAMES } from "~/shared/lib/lang";
|
||||
|
||||
interface SetupData { config: Record<string, string>; envLocked: string[]; }
|
||||
interface SetupData {
|
||||
config: Record<string, string>;
|
||||
envLocked: string[];
|
||||
}
|
||||
|
||||
let setupCache: SetupData | null = null;
|
||||
|
||||
@@ -16,7 +19,7 @@ const LANGUAGE_OPTIONS = Object.entries(LANG_NAMES).map(([code, label]) => ({ co
|
||||
function LockedInput({ locked, ...props }: { locked: boolean } & React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input {...props} disabled={locked || props.disabled} className={locked ? 'pr-9' : ''} />
|
||||
<Input {...props} disabled={locked || props.disabled} className={locked ? "pr-9" : ""} />
|
||||
{locked && (
|
||||
<span
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[0.9rem] opacity-40 pointer-events-none select-none"
|
||||
@@ -35,18 +38,28 @@ function EnvBadge({ envVar, locked }: { envVar: string; locked: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[0.67rem] font-semibold px-1.5 py-0.5 rounded bg-gray-100 text-gray-500 border border-gray-200"
|
||||
title={locked
|
||||
title={
|
||||
locked
|
||||
? `Set via environment variable ${envVar} — edit your .env file to change`
|
||||
: `Can be set via environment variable ${envVar}`}
|
||||
: `Can be set via environment variable ${envVar}`
|
||||
}
|
||||
>
|
||||
{locked ? '🔒' : '🔓'} <span className="font-mono">{envVar}</span>
|
||||
{locked ? "🔒" : "🔓"} <span className="font-mono">{envVar}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section card ──────────────────────────────────────────────────────────────
|
||||
|
||||
function SectionCard({ title, subtitle, children }: { title: React.ReactNode; subtitle?: React.ReactNode; children: React.ReactNode }) {
|
||||
function SectionCard({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-4 mb-4">
|
||||
<div className="font-semibold text-sm mb-1">{title}</div>
|
||||
@@ -59,9 +72,13 @@ function SectionCard({ title, subtitle, children }: { title: React.ReactNode; su
|
||||
// ─── Sortable language list ─────────────────────────────────────────────────────
|
||||
|
||||
function SortableLanguageList({
|
||||
langs, onChange, disabled,
|
||||
langs,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
langs: string[]; onChange: (langs: string[]) => void; disabled: boolean;
|
||||
langs: string[];
|
||||
onChange: (langs: string[]) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const available = LANGUAGE_OPTIONS.filter((o) => !langs.includes(o.code));
|
||||
|
||||
@@ -88,21 +105,32 @@ function SortableLanguageList({
|
||||
return (
|
||||
<div key={code} className="flex items-center gap-1.5 text-sm">
|
||||
<button
|
||||
type="button" disabled={disabled || i === 0}
|
||||
type="button"
|
||||
disabled={disabled || i === 0}
|
||||
onClick={() => move(i, -1)}
|
||||
className="w-6 h-6 flex items-center justify-center rounded border border-gray-200 bg-white text-gray-500 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer text-xs"
|
||||
>↑</button>
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button" disabled={disabled || i === langs.length - 1}
|
||||
type="button"
|
||||
disabled={disabled || i === langs.length - 1}
|
||||
onClick={() => move(i, 1)}
|
||||
className="w-6 h-6 flex items-center justify-center rounded border border-gray-200 bg-white text-gray-500 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer text-xs"
|
||||
>↓</button>
|
||||
<span className="min-w-[8rem]">{label} <span className="text-gray-400 text-xs font-mono">({code})</span></span>
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<span className="min-w-[8rem]">
|
||||
{label} <span className="text-gray-400 text-xs font-mono">({code})</span>
|
||||
</span>
|
||||
<button
|
||||
type="button" disabled={disabled}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => remove(i)}
|
||||
className="text-red-400 hover:text-red-600 text-sm border-0 bg-transparent cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>✕</button>
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -111,12 +139,17 @@ function SortableLanguageList({
|
||||
{!disabled && available.length > 0 && (
|
||||
<Select
|
||||
value=""
|
||||
onChange={(e) => { add(e.target.value); e.target.value = ''; }}
|
||||
onChange={(e) => {
|
||||
add(e.target.value);
|
||||
e.target.value = "";
|
||||
}}
|
||||
className="text-sm max-w-[14rem]"
|
||||
>
|
||||
<option value="">+ Add language…</option>
|
||||
{available.map(({ code, label }) => (
|
||||
<option key={code} value={code}>{label} ({code})</option>
|
||||
<option key={code} value={code}>
|
||||
{label} ({code})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
@@ -127,20 +160,38 @@ function SortableLanguageList({
|
||||
// ─── Connection section ────────────────────────────────────────────────────────
|
||||
|
||||
function ConnSection({
|
||||
title, subtitle, cfg, locked, urlKey, apiKey: apiKeyProp, urlPlaceholder, onSave,
|
||||
title,
|
||||
subtitle,
|
||||
cfg,
|
||||
locked,
|
||||
urlKey,
|
||||
apiKey: apiKeyProp,
|
||||
urlPlaceholder,
|
||||
onSave,
|
||||
}: {
|
||||
title: React.ReactNode; subtitle?: React.ReactNode; cfg: Record<string, string>; locked: Set<string>;
|
||||
urlKey: string; apiKey: string; urlPlaceholder: string; onSave: (url: string, apiKey: string) => Promise<void>;
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
cfg: Record<string, string>;
|
||||
locked: Set<string>;
|
||||
urlKey: string;
|
||||
apiKey: string;
|
||||
urlPlaceholder: string;
|
||||
onSave: (url: string, apiKey: string) => Promise<void>;
|
||||
}) {
|
||||
const [url, setUrl] = useState(cfg[urlKey] ?? '');
|
||||
const [key, setKey] = useState(cfg[apiKeyProp] ?? '');
|
||||
const [url, setUrl] = useState(cfg[urlKey] ?? "");
|
||||
const [key, setKey] = useState(cfg[apiKeyProp] ?? "");
|
||||
const [status, setStatus] = useState<{ ok: boolean; error?: string } | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setStatus(null);
|
||||
try { await onSave(url, key); setStatus({ ok: true }); } catch (e) { setStatus({ ok: false, error: String(e) }); }
|
||||
try {
|
||||
await onSave(url, key);
|
||||
setStatus({ ok: true });
|
||||
} catch (e) {
|
||||
setStatus({ ok: false, error: String(e) });
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
@@ -148,19 +199,32 @@ function ConnSection({
|
||||
<SectionCard title={title} subtitle={subtitle}>
|
||||
<label className="block text-sm text-gray-700 mb-1">
|
||||
URL
|
||||
<LockedInput locked={locked.has(urlKey)} type="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder={urlPlaceholder} className="mt-0.5 max-w-sm" />
|
||||
<LockedInput
|
||||
locked={locked.has(urlKey)}
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder={urlPlaceholder}
|
||||
className="mt-0.5 max-w-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-gray-700 mb-1 mt-3">
|
||||
API Key
|
||||
<LockedInput locked={locked.has(apiKeyProp)} value={key} onChange={(e) => setKey(e.target.value)} placeholder="your-api-key" className="mt-0.5 max-w-xs" />
|
||||
<LockedInput
|
||||
locked={locked.has(apiKeyProp)}
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
placeholder="your-api-key"
|
||||
className="mt-0.5 max-w-xs"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<Button onClick={save} disabled={saving || (locked.has(urlKey) && locked.has(apiKeyProp))}>
|
||||
{saving ? 'Saving…' : 'Test & Save'}
|
||||
{saving ? "Saving…" : "Test & Save"}
|
||||
</Button>
|
||||
{status && (
|
||||
<span className={`text-sm ${status.ok ? 'text-green-700' : 'text-red-600'}`}>
|
||||
{status.ok ? '✓ Saved' : `✗ ${status.error ?? 'Connection failed'}`}
|
||||
<span className={`text-sm ${status.ok ? "text-green-700" : "text-red-600"}`}>
|
||||
{status.ok ? "✓ Saved" : `✗ ${status.error ?? "Connection failed"}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -173,54 +237,61 @@ function ConnSection({
|
||||
export function SetupPage() {
|
||||
const [data, setData] = useState<SetupData | null>(setupCache);
|
||||
const [loading, setLoading] = useState(setupCache === null);
|
||||
const [clearStatus, setClearStatus] = useState('');
|
||||
const [clearStatus, setClearStatus] = useState("");
|
||||
const [subLangs, setSubLangs] = useState<string[]>([]);
|
||||
const [subSaved, setSubSaved] = useState('');
|
||||
const [subSaved, setSubSaved] = useState("");
|
||||
const [audLangs, setAudLangs] = useState<string[]>([]);
|
||||
const [audSaved, setAudSaved] = useState('');
|
||||
const [audSaved, setAudSaved] = useState("");
|
||||
const [langsLoaded, setLangsLoaded] = useState(false);
|
||||
|
||||
const load = () => {
|
||||
if (!setupCache) setLoading(true);
|
||||
api.get<SetupData>('/api/setup').then((d) => {
|
||||
api
|
||||
.get<SetupData>("/api/setup")
|
||||
.then((d) => {
|
||||
setupCache = d;
|
||||
setData(d);
|
||||
if (!langsLoaded) {
|
||||
setSubLangs(JSON.parse(d.config.subtitle_languages ?? '["eng","deu","spa"]'));
|
||||
setAudLangs(JSON.parse(d.config.audio_languages ?? '[]'));
|
||||
setAudLangs(JSON.parse(d.config.audio_languages ?? "[]"));
|
||||
setLangsLoaded(true);
|
||||
}
|
||||
}).finally(() => setLoading(false));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
if (loading && !data) return <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
||||
if (!data) return <div className="text-red-600">Failed to load settings.</div>;
|
||||
|
||||
const { config: cfg, envLocked: envLockedArr } = data;
|
||||
const locked = new Set(envLockedArr);
|
||||
const saveJellyfin = (url: string, apiKey: string) =>
|
||||
api.post('/api/setup/jellyfin', { url, api_key: apiKey });
|
||||
const saveRadarr = (url: string, apiKey: string) =>
|
||||
api.post('/api/setup/radarr', { url, api_key: apiKey });
|
||||
const saveSonarr = (url: string, apiKey: string) =>
|
||||
api.post('/api/setup/sonarr', { url, api_key: apiKey });
|
||||
const saveJellyfin = (url: string, apiKey: string) => api.post("/api/setup/jellyfin", { url, api_key: apiKey });
|
||||
const saveRadarr = (url: string, apiKey: string) => api.post("/api/setup/radarr", { url, api_key: apiKey });
|
||||
const saveSonarr = (url: string, apiKey: string) => api.post("/api/setup/sonarr", { url, api_key: apiKey });
|
||||
|
||||
const saveSubtitleLangs = async () => {
|
||||
await api.post('/api/setup/subtitle-languages', { langs: subLangs });
|
||||
setSubSaved('Saved.');
|
||||
setTimeout(() => setSubSaved(''), 2000);
|
||||
await api.post("/api/setup/subtitle-languages", { langs: subLangs });
|
||||
setSubSaved("Saved.");
|
||||
setTimeout(() => setSubSaved(""), 2000);
|
||||
};
|
||||
const saveAudioLangs = async () => {
|
||||
await api.post('/api/setup/audio-languages', { langs: audLangs });
|
||||
setAudSaved('Saved.');
|
||||
setTimeout(() => setAudSaved(''), 2000);
|
||||
await api.post("/api/setup/audio-languages", { langs: audLangs });
|
||||
setAudSaved("Saved.");
|
||||
setTimeout(() => setAudSaved(""), 2000);
|
||||
};
|
||||
|
||||
const clearScan = async () => {
|
||||
if (!confirm('Delete all scanned data? This will remove all media items, stream decisions, review plans, and jobs. This cannot be undone.')) return;
|
||||
await api.post('/api/setup/clear-scan');
|
||||
setClearStatus('Cleared.');
|
||||
if (
|
||||
!confirm(
|
||||
"Delete all scanned data? This will remove all media items, stream decisions, review plans, and jobs. This cannot be undone.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
await api.post("/api/setup/clear-scan");
|
||||
setClearStatus("Cleared.");
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -231,27 +302,53 @@ export function SetupPage() {
|
||||
|
||||
{/* Jellyfin */}
|
||||
<ConnSection
|
||||
title={<span className="flex items-center gap-2">Jellyfin <EnvBadge envVar="JELLYFIN_URL" locked={locked.has('jellyfin_url')} /> <EnvBadge envVar="JELLYFIN_API_KEY" locked={locked.has('jellyfin_api_key')} /></span>}
|
||||
urlKey="jellyfin_url" apiKey="jellyfin_api_key"
|
||||
urlPlaceholder="http://192.168.1.100:8096" cfg={cfg} locked={locked}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
Jellyfin <EnvBadge envVar="JELLYFIN_URL" locked={locked.has("jellyfin_url")} />{" "}
|
||||
<EnvBadge envVar="JELLYFIN_API_KEY" locked={locked.has("jellyfin_api_key")} />
|
||||
</span>
|
||||
}
|
||||
urlKey="jellyfin_url"
|
||||
apiKey="jellyfin_api_key"
|
||||
urlPlaceholder="http://192.168.1.100:8096"
|
||||
cfg={cfg}
|
||||
locked={locked}
|
||||
onSave={saveJellyfin}
|
||||
/>
|
||||
|
||||
{/* Radarr */}
|
||||
<ConnSection
|
||||
title={<span className="flex items-center gap-2">Radarr <span className="text-gray-400 font-normal">(optional)</span> <EnvBadge envVar="RADARR_URL" locked={locked.has('radarr_url')} /> <EnvBadge envVar="RADARR_API_KEY" locked={locked.has('radarr_api_key')} /></span>}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
Radarr <span className="text-gray-400 font-normal">(optional)</span>{" "}
|
||||
<EnvBadge envVar="RADARR_URL" locked={locked.has("radarr_url")} />{" "}
|
||||
<EnvBadge envVar="RADARR_API_KEY" locked={locked.has("radarr_api_key")} />
|
||||
</span>
|
||||
}
|
||||
subtitle="Provides accurate original-language data for movies."
|
||||
urlKey="radarr_url" apiKey="radarr_api_key"
|
||||
urlPlaceholder="http://192.168.1.100:7878" cfg={cfg} locked={locked}
|
||||
urlKey="radarr_url"
|
||||
apiKey="radarr_api_key"
|
||||
urlPlaceholder="http://192.168.1.100:7878"
|
||||
cfg={cfg}
|
||||
locked={locked}
|
||||
onSave={saveRadarr}
|
||||
/>
|
||||
|
||||
{/* Sonarr */}
|
||||
<ConnSection
|
||||
title={<span className="flex items-center gap-2">Sonarr <span className="text-gray-400 font-normal">(optional)</span> <EnvBadge envVar="SONARR_URL" locked={locked.has('sonarr_url')} /> <EnvBadge envVar="SONARR_API_KEY" locked={locked.has('sonarr_api_key')} /></span>}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
Sonarr <span className="text-gray-400 font-normal">(optional)</span>{" "}
|
||||
<EnvBadge envVar="SONARR_URL" locked={locked.has("sonarr_url")} />{" "}
|
||||
<EnvBadge envVar="SONARR_API_KEY" locked={locked.has("sonarr_api_key")} />
|
||||
</span>
|
||||
}
|
||||
subtitle="Provides original-language data for TV series."
|
||||
urlKey="sonarr_url" apiKey="sonarr_api_key"
|
||||
urlPlaceholder="http://192.168.1.100:8989" cfg={cfg} locked={locked}
|
||||
urlKey="sonarr_url"
|
||||
apiKey="sonarr_api_key"
|
||||
urlPlaceholder="http://192.168.1.100:8989"
|
||||
cfg={cfg}
|
||||
locked={locked}
|
||||
onSave={saveSonarr}
|
||||
/>
|
||||
|
||||
@@ -260,14 +357,16 @@ export function SetupPage() {
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
Audio Languages
|
||||
<EnvBadge envVar="AUDIO_LANGUAGES" locked={locked.has('audio_languages')} />
|
||||
<EnvBadge envVar="AUDIO_LANGUAGES" locked={locked.has("audio_languages")} />
|
||||
</span>
|
||||
}
|
||||
subtitle="Additional audio languages to keep alongside the original language. Order determines stream priority in the output file. The original language is always kept first."
|
||||
>
|
||||
<SortableLanguageList langs={audLangs} onChange={setAudLangs} disabled={locked.has('audio_languages')} />
|
||||
<SortableLanguageList langs={audLangs} onChange={setAudLangs} disabled={locked.has("audio_languages")} />
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<Button onClick={saveAudioLangs} disabled={locked.has('audio_languages')}>Save</Button>
|
||||
<Button onClick={saveAudioLangs} disabled={locked.has("audio_languages")}>
|
||||
Save
|
||||
</Button>
|
||||
{audSaved && <span className="text-green-700 text-sm">{audSaved}</span>}
|
||||
</div>
|
||||
</SectionCard>
|
||||
@@ -277,14 +376,16 @@ export function SetupPage() {
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
Subtitle Languages
|
||||
<EnvBadge envVar="SUBTITLE_LANGUAGES" locked={locked.has('subtitle_languages')} />
|
||||
<EnvBadge envVar="SUBTITLE_LANGUAGES" locked={locked.has("subtitle_languages")} />
|
||||
</span>
|
||||
}
|
||||
subtitle="Subtitle tracks in these languages are extracted to sidecar files. Order determines priority. All subtitles are removed from the container during processing."
|
||||
>
|
||||
<SortableLanguageList langs={subLangs} onChange={setSubLangs} disabled={locked.has('subtitle_languages')} />
|
||||
<SortableLanguageList langs={subLangs} onChange={setSubLangs} disabled={locked.has("subtitle_languages")} />
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<Button onClick={saveSubtitleLangs} disabled={locked.has('subtitle_languages')}>Save</Button>
|
||||
<Button onClick={saveSubtitleLangs} disabled={locked.has("subtitle_languages")}>
|
||||
Save
|
||||
</Button>
|
||||
{subSaved && <span className="text-green-700 text-sm">{subSaved}</span>}
|
||||
</div>
|
||||
</SectionCard>
|
||||
@@ -292,9 +393,13 @@ export function SetupPage() {
|
||||
{/* Danger zone */}
|
||||
<div className="border border-red-400 rounded-lg p-4 mb-4">
|
||||
<div className="font-semibold text-sm text-red-700 mb-1">Danger Zone</div>
|
||||
<p className="text-gray-500 text-sm mb-3">These actions are irreversible. Scan data can be regenerated by running a new scan.</p>
|
||||
<p className="text-gray-500 text-sm mb-3">
|
||||
These actions are irreversible. Scan data can be regenerated by running a new scan.
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="danger" onClick={clearScan}>Clear all scan data</Button>
|
||||
<Button variant="danger" onClick={clearScan}>
|
||||
Clear all scan data
|
||||
</Button>
|
||||
<span className="text-gray-400 text-sm">Removes all scanned items, review plans, and jobs.</span>
|
||||
</div>
|
||||
{clearStatus && <p className="text-green-700 text-sm mt-2">{clearStatus}</p>}
|
||||
@@ -303,4 +408,4 @@ export function SetupPage() {
|
||||
);
|
||||
}
|
||||
|
||||
import type React from 'react';
|
||||
import type React from "react";
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useParams } from '@tanstack/react-router';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
import { Alert } from '~/shared/components/ui/alert';
|
||||
import { Select } from '~/shared/components/ui/select';
|
||||
import { langName, LANG_NAMES } from '~/shared/lib/lang';
|
||||
import type { MediaItem, MediaStream, SubtitleFile, ReviewPlan, StreamDecision } from '~/shared/lib/types';
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert } from "~/shared/components/ui/alert";
|
||||
import { Badge } from "~/shared/components/ui/badge";
|
||||
import { Button } from "~/shared/components/ui/button";
|
||||
import { Select } from "~/shared/components/ui/select";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import { LANG_NAMES, langName } from "~/shared/lib/lang";
|
||||
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision, SubtitleFile } from "~/shared/lib/types";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -30,12 +30,12 @@ function formatBytes(bytes: number): string {
|
||||
}
|
||||
|
||||
function fileName(filePath: string): string {
|
||||
return filePath.split('/').pop() ?? filePath;
|
||||
return filePath.split("/").pop() ?? filePath;
|
||||
}
|
||||
|
||||
function effectiveTitle(s: MediaStream, dec: StreamDecision | undefined): string {
|
||||
if (dec?.custom_title) return dec.custom_title;
|
||||
if (!s.language) return '';
|
||||
if (!s.language) return "";
|
||||
const base = langName(s.language);
|
||||
if (s.is_forced) return `${base} (Forced)`;
|
||||
if (s.is_hearing_impaired) return `${base} (CC)`;
|
||||
@@ -46,14 +46,20 @@ function effectiveTitle(s: MediaStream, dec: StreamDecision | undefined): string
|
||||
|
||||
function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
|
||||
const [localVal, setLocalVal] = useState(value);
|
||||
useEffect(() => { setLocalVal(value); }, [value]);
|
||||
useEffect(() => {
|
||||
setLocalVal(value);
|
||||
}, [value]);
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={localVal}
|
||||
onChange={(e) => setLocalVal(e.target.value)}
|
||||
onBlur={(e) => { if (e.target.value !== value) onCommit(e.target.value); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
|
||||
onBlur={(e) => {
|
||||
if (e.target.value !== value) onCommit(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
}}
|
||||
placeholder="—"
|
||||
className="border border-transparent bg-transparent w-full text-[inherit] font-[inherit] text-inherit px-[0.2em] py-[0.05em] rounded outline-none hover:border-gray-300 hover:bg-gray-50 focus:border-blue-300 focus:bg-white min-w-16"
|
||||
/>
|
||||
@@ -74,11 +80,17 @@ function StreamTable({ streams, decisions, editable, onLanguageChange, onTitleCh
|
||||
if (streams.length === 0) return <p className="text-gray-500 text-sm">No subtitle streams in container.</p>;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0"><table className="w-full border-collapse text-[0.79rem] mt-1">
|
||||
<div className="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||
<table className="w-full border-collapse text-[0.79rem] mt-1">
|
||||
<thead>
|
||||
<tr>
|
||||
{['#', 'Codec', 'Language', 'Title / Info', 'Flags', 'Action'].map((h) => (
|
||||
<th key={h} className="text-left text-[0.67rem] uppercase tracking-[0.05em] text-gray-500 py-1 px-2 border-b border-gray-200">{h}</th>
|
||||
{["#", "Codec", "Language", "Title / Info", "Flags", "Action"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="text-left text-[0.67rem] uppercase tracking-[0.05em] text-gray-500 py-1 px-2 border-b border-gray-200"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -91,33 +103,33 @@ function StreamTable({ streams, decisions, editable, onLanguageChange, onTitleCh
|
||||
return (
|
||||
<tr key={s.id} className="bg-sky-50">
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.stream_index}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.codec ?? '—'}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.codec ?? "—"}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
{editable ? (
|
||||
<Select
|
||||
value={s.language ?? ''}
|
||||
value={s.language ?? ""}
|
||||
onChange={(e) => onLanguageChange(s.id, e.target.value)}
|
||||
className="text-[0.79rem] py-0.5 px-1.5 w-auto"
|
||||
>
|
||||
<option value="">— Unknown —</option>
|
||||
{Object.entries(LANG_NAMES).map(([code, name]) => (
|
||||
<option key={code} value={code}>{name} ({code})</option>
|
||||
<option key={code} value={code}>
|
||||
{name} ({code})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<>
|
||||
{langName(s.language)} {s.language ? <span className="text-gray-400 font-mono text-xs">({s.language})</span> : null}
|
||||
{langName(s.language)}{" "}
|
||||
{s.language ? <span className="text-gray-400 font-mono text-xs">({s.language})</span> : null}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
{editable ? (
|
||||
<TitleInput
|
||||
value={title}
|
||||
onCommit={(v) => onTitleChange(s.id, v)}
|
||||
/>
|
||||
<TitleInput value={title} onCommit={(v) => onTitleChange(s.id, v)} />
|
||||
) : (
|
||||
<span>{title || '—'}</span>
|
||||
<span>{title || "—"}</span>
|
||||
)}
|
||||
{editable && origTitle && origTitle !== title && (
|
||||
<div className="text-gray-400 text-[0.7rem] mt-0.5">orig: {origTitle}</div>
|
||||
@@ -139,7 +151,8 @@ function StreamTable({ streams, decisions, editable, onLanguageChange, onTitleCh
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table></div>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,24 +162,34 @@ function ExtractedFilesTable({ files, onDelete }: { files: SubtitleFile[]; onDel
|
||||
if (files.length === 0) return <p className="text-gray-500 text-sm">No extracted files yet.</p>;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0"><table className="w-full border-collapse text-[0.82rem]">
|
||||
<div className="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||
<table className="w-full border-collapse text-[0.82rem]">
|
||||
<thead>
|
||||
<tr>
|
||||
{['File', 'Language', 'Codec', 'Flags', 'Size', ''].map((h) => (
|
||||
<th key={h} 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">{h}</th>
|
||||
{["File", "Language", "Codec", "Flags", "Size", ""].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
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"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((f) => (
|
||||
<tr key={f.id} className="hover:bg-gray-50">
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs max-w-[200px] sm:max-w-[360px] truncate" title={f.file_path}>
|
||||
<td
|
||||
className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs max-w-[200px] sm:max-w-[360px] truncate"
|
||||
title={f.file_path}
|
||||
>
|
||||
{fileName(f.file_path)}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
{f.language ? langName(f.language) : '—'} {f.language ? <span className="text-gray-400 font-mono text-xs">({f.language})</span> : null}
|
||||
{f.language ? langName(f.language) : "—"}{" "}
|
||||
{f.language ? <span className="text-gray-400 font-mono text-xs">({f.language})</span> : null}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{f.codec ?? '—'}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{f.codec ?? "—"}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
<span className="inline-flex gap-1">
|
||||
{f.is_forced ? <Badge variant="manual">forced</Badge> : null}
|
||||
@@ -174,29 +197,41 @@ function ExtractedFilesTable({ files, onDelete }: { files: SubtitleFile[]; onDel
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">
|
||||
{f.file_size ? formatBytes(f.file_size) : '—'}
|
||||
{f.file_size ? formatBytes(f.file_size) : "—"}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 text-right">
|
||||
<Button variant="danger" size="xs" onClick={() => onDelete(f.id)}>Delete</Button>
|
||||
<Button variant="danger" size="xs" onClick={() => onDelete(f.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table></div>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Detail page ──────────────────────────────────────────────────────────────
|
||||
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [extracting, setExtracting] = useState(false);
|
||||
const [rescanning, setRescanning] = useState(false);
|
||||
|
||||
const load = () => api.get<DetailData>(`/api/subtitles/${id}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
|
||||
useEffect(() => { load(); }, [id]);
|
||||
const load = () =>
|
||||
api
|
||||
.get<DetailData>(`/api/subtitles/${id}`)
|
||||
.then((d) => {
|
||||
setData(d);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const changeLanguage = async (streamId: number, lang: string) => {
|
||||
const d = await api.patch<DetailData>(`/api/subtitles/${id}/stream/${streamId}/language`, { language: lang || null });
|
||||
@@ -213,7 +248,9 @@ export function SubtitleDetailPage() {
|
||||
try {
|
||||
await api.post(`/api/subtitles/${id}/extract`);
|
||||
load();
|
||||
} finally { setExtracting(false); }
|
||||
} finally {
|
||||
setExtracting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFile = async (fileId: number) => {
|
||||
@@ -223,8 +260,12 @@ export function SubtitleDetailPage() {
|
||||
|
||||
const rescan = async () => {
|
||||
setRescanning(true);
|
||||
try { const d = await api.post<DetailData>(`/api/subtitles/${id}/rescan`); setData(d); }
|
||||
finally { setRescanning(false); }
|
||||
try {
|
||||
const d = await api.post<DetailData>(`/api/subtitles/${id}/rescan`);
|
||||
setData(d);
|
||||
} finally {
|
||||
setRescanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
||||
@@ -238,7 +279,9 @@ export function SubtitleDetailPage() {
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h1 className="text-xl font-bold m-0">
|
||||
<Link to="/review/subtitles" className="font-normal mr-2 no-underline text-gray-500 hover:text-gray-700">← Subtitles</Link>
|
||||
<Link to="/review/subtitles" className="font-normal mr-2 no-underline text-gray-500 hover:text-gray-700">
|
||||
← Subtitles
|
||||
</Link>
|
||||
{item.name}
|
||||
</h1>
|
||||
</div>
|
||||
@@ -247,12 +290,15 @@ export function SubtitleDetailPage() {
|
||||
{/* Meta */}
|
||||
<dl className="flex flex-wrap gap-5 mb-3 text-[0.82rem]">
|
||||
{[
|
||||
{ label: 'Type', value: item.type },
|
||||
...(item.series_name ? [{ label: 'Series', value: item.series_name }] : []),
|
||||
...(item.year ? [{ label: 'Year', value: String(item.year) }] : []),
|
||||
{ label: 'Container', value: item.container ?? '—' },
|
||||
{ label: 'File size', value: item.file_size ? formatBytes(item.file_size) : '—' },
|
||||
{ label: 'Status', value: <Badge variant={subs_extracted ? 'done' : 'pending'}>{subs_extracted ? 'extracted' : 'pending'}</Badge> },
|
||||
{ label: "Type", value: item.type },
|
||||
...(item.series_name ? [{ label: "Series", value: item.series_name }] : []),
|
||||
...(item.year ? [{ label: "Year", value: String(item.year) }] : []),
|
||||
{ label: "Container", value: item.container ?? "—" },
|
||||
{ label: "File size", value: item.file_size ? formatBytes(item.file_size) : "—" },
|
||||
{
|
||||
label: "Status",
|
||||
value: <Badge variant={subs_extracted ? "done" : "pending"}>{subs_extracted ? "extracted" : "pending"}</Badge>,
|
||||
},
|
||||
].map((entry, i) => (
|
||||
<div key={i}>
|
||||
<dt className="text-gray-500 text-[0.68rem] uppercase tracking-[0.05em] mb-0.5">{entry.label}</dt>
|
||||
@@ -273,7 +319,9 @@ export function SubtitleDetailPage() {
|
||||
onTitleChange={changeTitle}
|
||||
/>
|
||||
) : (
|
||||
<Alert variant="warning" className="mb-4">No subtitle streams found in this container.</Alert>
|
||||
<Alert variant="warning" className="mb-4">
|
||||
No subtitle streams found in this container.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Extracted files */}
|
||||
@@ -301,22 +349,26 @@ export function SubtitleDetailPage() {
|
||||
{hasContainerSubs && !subs_extracted && (
|
||||
<div className="flex gap-2 mt-6">
|
||||
<Button onClick={extract} disabled={extracting}>
|
||||
{extracting ? 'Queuing…' : '✓ Extract All'}
|
||||
{extracting ? "Queuing…" : "✓ Extract All"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subs_extracted ? (
|
||||
<Alert variant="success" className="mt-4">Subtitles have been extracted to sidecar files.</Alert>
|
||||
<Alert variant="success" className="mt-4">
|
||||
Subtitles have been extracted to sidecar files.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{/* Refresh */}
|
||||
<div className="flex items-center gap-3 mt-6 pt-3 border-t border-gray-200">
|
||||
<Button variant="secondary" size="sm" onClick={rescan} disabled={rescanning}>
|
||||
{rescanning ? '↻ Refreshing…' : '↻ Refresh from Jellyfin'}
|
||||
{rescanning ? "↻ Refreshing…" : "↻ Refresh from Jellyfin"}
|
||||
</Button>
|
||||
<span className="text-gray-400 text-[0.75rem]">
|
||||
{rescanning ? 'Triggering Jellyfin metadata probe and waiting for completion…' : 'Triggers a metadata re-probe in Jellyfin, then re-fetches stream data'}
|
||||
{rescanning
|
||||
? "Triggering Jellyfin metadata probe and waiting for completion…"
|
||||
: "Triggers a metadata re-probe in Jellyfin, then re-fetches stream data"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
import { FilterTabs } from '~/shared/components/ui/filter-tabs';
|
||||
import { langName } from '~/shared/lib/lang';
|
||||
import type React from 'react';
|
||||
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;
|
||||
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;
|
||||
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 {
|
||||
@@ -32,14 +44,16 @@ interface SubListData {
|
||||
interface SeasonGroup {
|
||||
season: number | null;
|
||||
episodes: SubListItem[];
|
||||
extractedCount: number; notExtractedCount: number; noSubsCount: number;
|
||||
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' },
|
||||
{ key: "all", label: "All" },
|
||||
{ key: "not_extracted", label: "Not Extracted" },
|
||||
{ key: "extracted", label: "Extracted" },
|
||||
{ key: "no_subs", label: "No Subtitles" },
|
||||
];
|
||||
|
||||
// ─── Table helpers ────────────────────────────────────────────────────────────
|
||||
@@ -51,27 +65,39 @@ const Th = ({ children }: { children?: React.ReactNode }) => (
|
||||
);
|
||||
|
||||
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>
|
||||
<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 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>;
|
||||
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>}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -80,16 +106,18 @@ function StatusPills({ g }: { g: SubSeriesGroup }) {
|
||||
|
||||
function ActionBox({ count, onExtract }: { count: number | null; onExtract: () => void }) {
|
||||
const [extracting, setExtracting] = useState(false);
|
||||
const [result, setResult] = useState('');
|
||||
const [result, setResult] = useState("");
|
||||
|
||||
const handleExtract = async () => {
|
||||
setExtracting(true);
|
||||
setResult('');
|
||||
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' : ''}.`);
|
||||
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}`); }
|
||||
} catch (e) {
|
||||
setResult(`Error: ${e}`);
|
||||
}
|
||||
setExtracting(false);
|
||||
};
|
||||
|
||||
@@ -101,9 +129,11 @@ function ActionBox({ count, onExtract }: { count: number | null; onExtract: () =
|
||||
{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>
|
||||
<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'}
|
||||
{extracting ? "Queuing..." : "Extract All"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -131,13 +161,19 @@ function SeriesRow({ g }: { g: SubSeriesGroup }) {
|
||||
<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>
|
||||
<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>
|
||||
<Td>
|
||||
<StatusPills g={g} />
|
||||
</Td>
|
||||
</tr>
|
||||
{open && seasons && (
|
||||
<tr>
|
||||
@@ -147,21 +183,41 @@ function SeriesRow({ g }: { g: SubSeriesGroup }) {
|
||||
{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 ?? '?'}
|
||||
<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>}
|
||||
{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="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>
|
||||
@@ -171,7 +227,11 @@ function SeriesRow({ g }: { g: SubSeriesGroup }) {
|
||||
<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">
|
||||
<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>
|
||||
@@ -195,7 +255,7 @@ const cache = new Map<string, SubListData>();
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function SubtitleExtractPage() {
|
||||
const { filter } = useSearch({ from: '/review/subtitles/extract' });
|
||||
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));
|
||||
@@ -203,21 +263,33 @@ export function SubtitleExtractPage() {
|
||||
|
||||
const load = () => {
|
||||
if (!cache.has(filter)) setLoading(true);
|
||||
api.get<SubListData>(`/api/subtitles?filter=${filter}`)
|
||||
.then((d) => { cache.set(filter, d); setData(d); })
|
||||
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')
|
||||
api
|
||||
.get<{ embeddedCount: number }>("/api/subtitles/summary")
|
||||
.then((d) => setEmbeddedCount(d.embeddedCount))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => { load(); loadEmbedded(); }, [filter]);
|
||||
useEffect(() => {
|
||||
load();
|
||||
loadEmbedded();
|
||||
}, [load, loadEmbedded]);
|
||||
|
||||
const refresh = () => { cache.clear(); load(); loadEmbedded(); };
|
||||
const refresh = () => {
|
||||
cache.clear();
|
||||
load();
|
||||
loadEmbedded();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -229,7 +301,7 @@ export function SubtitleExtractPage() {
|
||||
tabs={FILTER_TABS}
|
||||
filter={filter}
|
||||
totalCounts={data?.totalCounts ?? {}}
|
||||
onFilterChange={(key) => navigate({ to: '/review/subtitles/extract', search: { filter: key } as never })}
|
||||
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>}
|
||||
@@ -247,20 +319,36 @@ export function SubtitleExtractPage() {
|
||||
</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>
|
||||
<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
|
||||
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>
|
||||
<Td>
|
||||
<StatusBadge item={item} />
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -271,13 +359,25 @@ export function SubtitleExtractPage() {
|
||||
|
||||
{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'}`}>
|
||||
<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} />)}
|
||||
<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>
|
||||
</>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
import { langName } from '~/shared/lib/lang';
|
||||
import type React from 'react';
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "~/shared/components/ui/button";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import { langName } from "~/shared/lib/lang";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SummaryCategory {
|
||||
language: string | null;
|
||||
variant: 'standard' | 'forced' | 'cc';
|
||||
variant: "standard" | "forced" | "cc";
|
||||
streamCount: number;
|
||||
fileCount: number;
|
||||
}
|
||||
@@ -36,18 +36,22 @@ const Th = ({ children }: { children?: React.ReactNode }) => (
|
||||
);
|
||||
|
||||
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>
|
||||
<td className={`py-1.5 px-2 border-b border-gray-100 align-middle ${className ?? ""}`}>{children}</td>
|
||||
);
|
||||
|
||||
// ─── Language summary table ───────────────────────────────────────────────────
|
||||
|
||||
function variantLabel(v: string): string {
|
||||
if (v === 'forced') return 'Forced';
|
||||
if (v === 'cc') return 'CC';
|
||||
return 'Standard';
|
||||
if (v === "forced") return "Forced";
|
||||
if (v === "cc") return "CC";
|
||||
return "Standard";
|
||||
}
|
||||
|
||||
function LanguageSummary({ categories, keepLanguages, onDelete }: {
|
||||
function LanguageSummary({
|
||||
categories,
|
||||
keepLanguages,
|
||||
onDelete,
|
||||
}: {
|
||||
categories: SummaryCategory[];
|
||||
keepLanguages: string[];
|
||||
onDelete: () => void;
|
||||
@@ -57,21 +61,21 @@ function LanguageSummary({ categories, keepLanguages, onDelete }: {
|
||||
const [checked, setChecked] = useState<Record<string, boolean>>(() => {
|
||||
const init: Record<string, boolean> = {};
|
||||
for (const cat of categories) {
|
||||
const key = `${cat.language ?? '__null__'}|${cat.variant}`;
|
||||
const key = `${cat.language ?? "__null__"}|${cat.variant}`;
|
||||
init[key] = cat.language !== null && keepSet.has(cat.language);
|
||||
}
|
||||
return init;
|
||||
});
|
||||
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [result, setResult] = useState('');
|
||||
const [result, setResult] = useState("");
|
||||
|
||||
if (categories.length === 0) return null;
|
||||
|
||||
const toggle = (key: string) => setChecked((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
const uncheckedCategories = categories.filter((cat) => {
|
||||
const key = `${cat.language ?? '__null__'}|${cat.variant}`;
|
||||
const key = `${cat.language ?? "__null__"}|${cat.variant}`;
|
||||
return !checked[key] && cat.fileCount > 0;
|
||||
});
|
||||
|
||||
@@ -82,12 +86,14 @@ function LanguageSummary({ categories, keepLanguages, onDelete }: {
|
||||
variant: cat.variant,
|
||||
}));
|
||||
setDeleting(true);
|
||||
setResult('');
|
||||
setResult("");
|
||||
try {
|
||||
const r = await api.post<{ ok: boolean; deleted: number }>('/api/subtitles/batch-delete', { categories: toDelete });
|
||||
setResult(`Deleted ${r.deleted} file${r.deleted !== 1 ? 's' : ''}.`);
|
||||
const r = await api.post<{ ok: boolean; deleted: number }>("/api/subtitles/batch-delete", { categories: toDelete });
|
||||
setResult(`Deleted ${r.deleted} file${r.deleted !== 1 ? "s" : ""}.`);
|
||||
onDelete();
|
||||
} catch (e) { setResult(`Error: ${e}`); }
|
||||
} catch (e) {
|
||||
setResult(`Error: ${e}`);
|
||||
}
|
||||
setDeleting(false);
|
||||
};
|
||||
|
||||
@@ -107,7 +113,7 @@ function LanguageSummary({ categories, keepLanguages, onDelete }: {
|
||||
</thead>
|
||||
<tbody>
|
||||
{categories.map((cat) => {
|
||||
const key = `${cat.language ?? '__null__'}|${cat.variant}`;
|
||||
const key = `${cat.language ?? "__null__"}|${cat.variant}`;
|
||||
return (
|
||||
<tr key={key} className="hover:bg-gray-50">
|
||||
<Td>
|
||||
@@ -129,17 +135,13 @@ function LanguageSummary({ categories, keepLanguages, onDelete }: {
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting || uncheckedCategories.length === 0}
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Delete Unchecked Files'}
|
||||
<Button size="sm" variant="danger" onClick={handleDelete} disabled={deleting || uncheckedCategories.length === 0}>
|
||||
{deleting ? "Deleting..." : "Delete Unchecked Files"}
|
||||
</Button>
|
||||
{uncheckedCategories.length > 0 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{uncheckedCategories.reduce((sum, c) => sum + c.fileCount, 0)} file{uncheckedCategories.reduce((sum, c) => sum + c.fileCount, 0) !== 1 ? 's' : ''} will be removed
|
||||
{uncheckedCategories.reduce((sum, c) => sum + c.fileCount, 0)} file
|
||||
{uncheckedCategories.reduce((sum, c) => sum + c.fileCount, 0) !== 1 ? "s" : ""} will be removed
|
||||
</span>
|
||||
)}
|
||||
{result && <span className="text-sm text-gray-600">{result}</span>}
|
||||
@@ -150,24 +152,23 @@ function LanguageSummary({ categories, keepLanguages, onDelete }: {
|
||||
|
||||
// ─── Title harmonization ──────────────────────────────────────────────────────
|
||||
|
||||
function TitleHarmonization({ titles, onNormalize }: {
|
||||
titles: SummaryTitle[];
|
||||
onNormalize: () => void;
|
||||
}) {
|
||||
function TitleHarmonization({ titles, onNormalize }: { titles: SummaryTitle[]; onNormalize: () => void }) {
|
||||
const [normalizing, setNormalizing] = useState(false);
|
||||
const [result, setResult] = useState('');
|
||||
const [result, setResult] = useState("");
|
||||
|
||||
const nonCanonical = titles.filter((t) => !t.isCanonical);
|
||||
if (nonCanonical.length === 0) return null;
|
||||
|
||||
const handleNormalizeAll = async () => {
|
||||
setNormalizing(true);
|
||||
setResult('');
|
||||
setResult("");
|
||||
try {
|
||||
const r = await api.post<{ ok: boolean; normalized: number }>('/api/subtitles/normalize-titles');
|
||||
setResult(`Normalized ${r.normalized} stream${r.normalized !== 1 ? 's' : ''}.`);
|
||||
const r = await api.post<{ ok: boolean; normalized: number }>("/api/subtitles/normalize-titles");
|
||||
setResult(`Normalized ${r.normalized} stream${r.normalized !== 1 ? "s" : ""}.`);
|
||||
onNormalize();
|
||||
} catch (e) { setResult(`Error: ${e}`); }
|
||||
} catch (e) {
|
||||
setResult(`Error: ${e}`);
|
||||
}
|
||||
setNormalizing(false);
|
||||
};
|
||||
|
||||
@@ -181,7 +182,8 @@ function TitleHarmonization({ titles, onNormalize }: {
|
||||
return (
|
||||
<details className="mb-6">
|
||||
<summary className="text-sm font-bold uppercase tracking-wide text-gray-500 mb-2 cursor-pointer select-none">
|
||||
Title Harmonization <span className="text-xs font-normal normal-case text-amber-600">({nonCanonical.length} non-canonical)</span>
|
||||
Title Harmonization{" "}
|
||||
<span className="text-xs font-normal normal-case text-amber-600">({nonCanonical.length} non-canonical)</span>
|
||||
</summary>
|
||||
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0 mt-2">
|
||||
<table className="w-full border-collapse text-[0.82rem]">
|
||||
@@ -199,19 +201,13 @@ function TitleHarmonization({ titles, onNormalize }: {
|
||||
<tr key={`${lang}|${t.title}`} className="hover:bg-gray-50">
|
||||
<Td>{langName(lang)}</Td>
|
||||
<Td>
|
||||
<span className={`font-mono text-xs ${t.isCanonical ? 'text-gray-900' : 'text-amber-700'}`}>
|
||||
{t.title ? `"${t.title}"` : '(none)'}
|
||||
<span className={`font-mono text-xs ${t.isCanonical ? "text-gray-900" : "text-amber-700"}`}>
|
||||
{t.title ? `"${t.title}"` : "(none)"}
|
||||
</span>
|
||||
{t.isCanonical && <span className="ml-2 text-[0.68rem] text-gray-400">(canonical)</span>}
|
||||
</Td>
|
||||
<Td className="font-mono text-xs">{t.count}</Td>
|
||||
<Td>
|
||||
{!t.isCanonical && (
|
||||
<span className="text-[0.72rem] text-gray-400">
|
||||
will normalize
|
||||
</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td>{!t.isCanonical && <span className="text-[0.72rem] text-gray-400">will normalize</span>}</Td>
|
||||
</tr>
|
||||
)),
|
||||
)}
|
||||
@@ -220,7 +216,7 @@ function TitleHarmonization({ titles, onNormalize }: {
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<Button size="sm" onClick={handleNormalizeAll} disabled={normalizing}>
|
||||
{normalizing ? 'Normalizing...' : 'Normalize All'}
|
||||
{normalizing ? "Normalizing..." : "Normalize All"}
|
||||
</Button>
|
||||
{result && <span className="text-sm text-gray-600">{result}</span>}
|
||||
</div>
|
||||
@@ -240,13 +236,19 @@ export function SubtitleListPage() {
|
||||
|
||||
const loadSummary = () => {
|
||||
if (!summaryCache) setLoading(true);
|
||||
api.get<SummaryData>('/api/subtitles/summary')
|
||||
.then((d) => { summaryCache = d; setSummary(d); })
|
||||
api
|
||||
.get<SummaryData>("/api/subtitles/summary")
|
||||
.then((d) => {
|
||||
summaryCache = d;
|
||||
setSummary(d);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { loadSummary(); }, []);
|
||||
useEffect(() => {
|
||||
loadSummary();
|
||||
}, [loadSummary]);
|
||||
|
||||
const refresh = () => {
|
||||
summaryCache = null;
|
||||
@@ -264,19 +266,20 @@ export function SubtitleListPage() {
|
||||
<div>
|
||||
<h1 className="text-xl font-bold mb-4">Subtitle Manager</h1>
|
||||
|
||||
<div className={`rounded-lg px-4 py-3 mb-6 flex items-center gap-3 flex-wrap ${hasFiles ? 'border border-gray-200' : 'border border-gray-200'}`}>
|
||||
<div
|
||||
className={`rounded-lg px-4 py-3 mb-6 flex items-center gap-3 flex-wrap ${hasFiles ? "border border-gray-200" : "border border-gray-200"}`}
|
||||
>
|
||||
{hasFiles ? (
|
||||
<span className="text-sm font-medium">{totalFiles} extracted file{totalFiles !== 1 ? 's' : ''} across {langCount} language{langCount !== 1 ? 's' : ''} — select which to keep below</span>
|
||||
<span className="text-sm font-medium">
|
||||
{totalFiles} extracted file{totalFiles !== 1 ? "s" : ""} across {langCount} language{langCount !== 1 ? "s" : ""} —
|
||||
select which to keep below
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500">No extracted subtitle files yet. Extract subtitles first.</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<LanguageSummary
|
||||
categories={summary.categories}
|
||||
keepLanguages={summary.keepLanguages}
|
||||
onDelete={refresh}
|
||||
/>
|
||||
<LanguageSummary categories={summary.categories} keepLanguages={summary.keepLanguages} onDelete={refresh} />
|
||||
|
||||
<TitleHarmonization titles={summary.titles} onNormalize={refresh} />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; }
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
22
src/main.tsx
22
src/main.tsx
@@ -1,17 +1,19 @@
|
||||
import './index.css';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider, createRouter } from '@tanstack/react-router';
|
||||
import { routeTree } from './routeTree.gen';
|
||||
import "./index.css";
|
||||
import { createRouter, RouterProvider } from "@tanstack/react-router";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
const router = createRouter({ routeTree, defaultPreload: 'intent' });
|
||||
const router = createRouter({ routeTree, defaultPreload: "intent" });
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register { router: typeof router; }
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (!root) throw new Error('No #root element found');
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("No #root element found");
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createRootRoute, Link, Outlet } from '@tanstack/react-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from '~/shared/lib/utils';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { createRootRoute, Link, Outlet } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import { cn } from "~/shared/lib/utils";
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
@@ -13,8 +13,10 @@ function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={cn('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' }}
|
||||
className={cn(
|
||||
"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" }}
|
||||
activeOptions={{ exact: true }}
|
||||
>
|
||||
{children}
|
||||
@@ -24,13 +26,25 @@ function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
|
||||
|
||||
function VersionBadge() {
|
||||
const [serverVersion, setServerVersion] = useState<string | null>(null);
|
||||
useEffect(() => { api.get<{ version: string }>('/api/version').then((d) => setServerVersion(d.version)).catch(() => {}); }, []);
|
||||
const buildVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : null;
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<{ version: string }>("/api/version")
|
||||
.then((d) => setServerVersion(d.version))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const buildVersion = typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : null;
|
||||
const mismatch = buildVersion && serverVersion && buildVersion !== serverVersion;
|
||||
return (
|
||||
<span className="text-[0.65rem] text-gray-400 font-mono ml-1" title={mismatch ? `Frontend: ${buildVersion}, Server: ${serverVersion}` : undefined}>
|
||||
v{serverVersion ?? buildVersion ?? '?'}
|
||||
{mismatch && <span className="text-amber-500 ml-1" title="Frontend and server versions differ — rebuild or refresh">⚠</span>}
|
||||
<span
|
||||
className="text-[0.65rem] text-gray-400 font-mono ml-1"
|
||||
title={mismatch ? `Frontend: ${buildVersion}, Server: ${serverVersion}` : undefined}
|
||||
>
|
||||
v{serverVersion ?? buildVersion ?? "?"}
|
||||
{mismatch && (
|
||||
<span className="text-amber-500 ml-1" title="Frontend and server versions differ — rebuild or refresh">
|
||||
⚠
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -66,4 +80,4 @@ function RootLayout() {
|
||||
);
|
||||
}
|
||||
|
||||
import type React from 'react';
|
||||
import type React from "react";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
import { ExecutePage } from '~/features/execute/ExecutePage';
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
import { ExecutePage } from "~/features/execute/ExecutePage";
|
||||
|
||||
export const Route = createFileRoute('/execute')({
|
||||
export const Route = createFileRoute("/execute")({
|
||||
validateSearch: z.object({
|
||||
filter: z.enum(['all', 'pending', 'running', 'done', 'error']).default('pending'),
|
||||
filter: z.enum(["all", "pending", "running", "done", "error"]).default("pending"),
|
||||
}),
|
||||
component: ExecutePage,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { DashboardPage } from '~/features/dashboard/DashboardPage';
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { DashboardPage } from "~/features/dashboard/DashboardPage";
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
export const Route = createFileRoute("/")({
|
||||
component: DashboardPage,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { PathsPage } from '~/features/paths/PathsPage';
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { PathsPage } from "~/features/paths/PathsPage";
|
||||
|
||||
export const Route = createFileRoute('/paths')({
|
||||
export const Route = createFileRoute("/paths")({
|
||||
component: PathsPage,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { PipelinePage } from '~/features/pipeline/PipelinePage';
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { PipelinePage } from "~/features/pipeline/PipelinePage";
|
||||
|
||||
export const Route = createFileRoute('/pipeline')({
|
||||
export const Route = createFileRoute("/pipeline")({
|
||||
component: PipelinePage,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router';
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute('/review')({
|
||||
export const Route = createFileRoute("/review")({
|
||||
component: () => <Outlet />,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { AudioDetailPage } from '~/features/review/AudioDetailPage';
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { AudioDetailPage } from "~/features/review/AudioDetailPage";
|
||||
|
||||
export const Route = createFileRoute('/review/audio/$id')({
|
||||
export const Route = createFileRoute("/review/audio/$id")({
|
||||
component: AudioDetailPage,
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
import { AudioListPage } from '~/features/review/AudioListPage';
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
import { AudioListPage } from "~/features/review/AudioListPage";
|
||||
|
||||
export const Route = createFileRoute('/review/audio/')({
|
||||
export const Route = createFileRoute("/review/audio/")({
|
||||
validateSearch: z.object({
|
||||
filter: z.enum(['all', 'needs_action', 'noop', 'manual', 'approved', 'skipped', 'done', 'error']).default('all'),
|
||||
filter: z.enum(["all", "needs_action", "noop", "manual", "approved", "skipped", "done", "error"]).default("all"),
|
||||
}),
|
||||
component: AudioListPage,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute('/review/')({
|
||||
beforeLoad: () => { throw redirect({ to: '/review/audio' }); },
|
||||
export const Route = createFileRoute("/review/")({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/review/audio" });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { SubtitleDetailPage } from '~/features/subtitles/SubtitleDetailPage';
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SubtitleDetailPage } from "~/features/subtitles/SubtitleDetailPage";
|
||||
|
||||
export const Route = createFileRoute('/review/subtitles/$id')({
|
||||
export const Route = createFileRoute("/review/subtitles/$id")({
|
||||
component: SubtitleDetailPage,
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
import { SubtitleExtractPage } from '~/features/subtitles/SubtitleExtractPage';
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
import { SubtitleExtractPage } from "~/features/subtitles/SubtitleExtractPage";
|
||||
|
||||
export const Route = createFileRoute('/review/subtitles/extract')({
|
||||
export const Route = createFileRoute("/review/subtitles/extract")({
|
||||
validateSearch: z.object({
|
||||
filter: z.enum(['all', 'not_extracted', 'extracted', 'no_subs']).default('not_extracted'),
|
||||
filter: z.enum(["all", "not_extracted", "extracted", "no_subs"]).default("not_extracted"),
|
||||
}),
|
||||
component: SubtitleExtractPage,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { SubtitleListPage } from '~/features/subtitles/SubtitleListPage';
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SubtitleListPage } from "~/features/subtitles/SubtitleListPage";
|
||||
|
||||
export const Route = createFileRoute('/review/subtitles/')({
|
||||
export const Route = createFileRoute("/review/subtitles/")({
|
||||
component: SubtitleListPage,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { ScanPage } from '~/features/scan/ScanPage';
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { ScanPage } from "~/features/scan/ScanPage";
|
||||
|
||||
export const Route = createFileRoute('/scan')({
|
||||
export const Route = createFileRoute("/scan")({
|
||||
component: ScanPage,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { SetupPage } from '~/features/setup/SetupPage';
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SetupPage } from "~/features/setup/SetupPage";
|
||||
|
||||
export const Route = createFileRoute('/settings')({
|
||||
export const Route = createFileRoute("/settings")({
|
||||
component: SetupPage,
|
||||
});
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import type React from 'react';
|
||||
import { cn } from '~/shared/lib/utils';
|
||||
import type React from "react";
|
||||
import { cn } from "~/shared/lib/utils";
|
||||
|
||||
const variants = {
|
||||
info: 'bg-cyan-50 text-cyan-800 border border-cyan-200',
|
||||
warning: 'bg-amber-50 text-amber-800 border border-amber-200',
|
||||
error: 'bg-red-50 text-red-800 border border-red-200',
|
||||
success: 'bg-green-50 text-green-800 border border-green-200',
|
||||
info: "bg-cyan-50 text-cyan-800 border border-cyan-200",
|
||||
warning: "bg-amber-50 text-amber-800 border border-amber-200",
|
||||
error: "bg-red-50 text-red-800 border border-red-200",
|
||||
success: "bg-green-50 text-green-800 border border-green-200",
|
||||
} as const;
|
||||
|
||||
interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: keyof typeof variants;
|
||||
}
|
||||
|
||||
export function Alert({ variant = 'info', className, children, ...props }: AlertProps) {
|
||||
export function Alert({ variant = "info", className, children, ...props }: AlertProps) {
|
||||
return (
|
||||
<div className={cn('p-3 rounded text-sm', variants[variant], className)} {...props}>
|
||||
<div className={cn("p-3 rounded text-sm", variants[variant], className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { cn } from '~/shared/lib/utils';
|
||||
import { cn } from "~/shared/lib/utils";
|
||||
|
||||
const variants = {
|
||||
default: 'bg-gray-100 text-gray-600',
|
||||
keep: 'bg-green-100 text-green-800',
|
||||
remove: 'bg-red-100 text-red-800',
|
||||
pending: 'bg-gray-200 text-gray-600',
|
||||
approved: 'bg-green-100 text-green-800',
|
||||
skipped: 'bg-gray-200 text-gray-600',
|
||||
done: 'bg-cyan-100 text-cyan-800',
|
||||
error: 'bg-red-100 text-red-800',
|
||||
noop: 'bg-gray-200 text-gray-600',
|
||||
running: 'bg-amber-100 text-amber-800',
|
||||
manual: 'bg-orange-100 text-orange-800',
|
||||
default: "bg-gray-100 text-gray-600",
|
||||
keep: "bg-green-100 text-green-800",
|
||||
remove: "bg-red-100 text-red-800",
|
||||
pending: "bg-gray-200 text-gray-600",
|
||||
approved: "bg-green-100 text-green-800",
|
||||
skipped: "bg-gray-200 text-gray-600",
|
||||
done: "bg-cyan-100 text-cyan-800",
|
||||
error: "bg-red-100 text-red-800",
|
||||
noop: "bg-gray-200 text-gray-600",
|
||||
running: "bg-amber-100 text-amber-800",
|
||||
manual: "bg-orange-100 text-orange-800",
|
||||
} as const;
|
||||
|
||||
interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: keyof typeof variants;
|
||||
}
|
||||
|
||||
export function Badge({ variant = 'default', className, children, ...props }: BadgeProps) {
|
||||
export function Badge({ variant = "default", className, children, ...props }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block text-[0.67rem] font-semibold px-[0.45em] py-[0.1em] rounded-full uppercase tracking-[0.03em] whitespace-nowrap',
|
||||
"inline-block text-[0.67rem] font-semibold px-[0.45em] py-[0.1em] rounded-full uppercase tracking-[0.03em] whitespace-nowrap",
|
||||
variants[variant],
|
||||
className,
|
||||
)}
|
||||
@@ -33,4 +33,4 @@ export function Badge({ variant = 'default', className, children, ...props }: Ba
|
||||
);
|
||||
}
|
||||
|
||||
import type React from 'react';
|
||||
import type React from "react";
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import type React from 'react';
|
||||
import { cn } from '~/shared/lib/utils';
|
||||
import type React from "react";
|
||||
import { cn } from "~/shared/lib/utils";
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
size?: 'default' | 'sm' | 'xs';
|
||||
variant?: "primary" | "secondary" | "danger";
|
||||
size?: "default" | "sm" | "xs";
|
||||
}
|
||||
|
||||
export function Button({ variant = 'primary', size = 'default', className, ...props }: ButtonProps) {
|
||||
export function Button({ variant = "primary", size = "default", className, ...props }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-1 rounded font-medium cursor-pointer transition-colors border-0',
|
||||
variant === 'primary' && 'bg-blue-600 text-white hover:bg-blue-700',
|
||||
variant === 'secondary' && 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50',
|
||||
variant === 'danger' && 'bg-white text-red-600 border border-red-400 hover:bg-red-50',
|
||||
size === 'default' && 'px-3 py-1.5 text-sm',
|
||||
size === 'sm' && 'px-2.5 py-1 text-xs',
|
||||
size === 'xs' && 'px-2 py-0.5 text-xs',
|
||||
props.disabled && 'opacity-50 cursor-not-allowed',
|
||||
"inline-flex items-center justify-center gap-1 rounded font-medium cursor-pointer transition-colors border-0",
|
||||
variant === "primary" && "bg-blue-600 text-white hover:bg-blue-700",
|
||||
variant === "secondary" && "bg-white text-gray-700 border border-gray-300 hover:bg-gray-50",
|
||||
variant === "danger" && "bg-white text-red-600 border border-red-400 hover:bg-red-50",
|
||||
size === "default" && "px-3 py-1.5 text-sm",
|
||||
size === "sm" && "px-2.5 py-1 text-xs",
|
||||
size === "xs" && "px-2 py-0.5 text-xs",
|
||||
props.disabled && "opacity-50 cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -11,20 +11,20 @@ interface FilterTabsProps {
|
||||
}
|
||||
|
||||
const ACTIVE_COLORS: Record<string, string> = {
|
||||
all: 'bg-blue-600 border-blue-600',
|
||||
pending: 'bg-gray-500 border-gray-500',
|
||||
needs_action: 'bg-gray-500 border-gray-500',
|
||||
noop: 'bg-gray-500 border-gray-500',
|
||||
not_extracted: 'bg-gray-500 border-gray-500',
|
||||
no_subs: 'bg-gray-500 border-gray-500',
|
||||
skipped: 'bg-gray-500 border-gray-500',
|
||||
running: 'bg-amber-500 border-amber-500',
|
||||
done: 'bg-green-600 border-green-600',
|
||||
approved: 'bg-green-600 border-green-600',
|
||||
extracted: 'bg-green-600 border-green-600',
|
||||
keep: 'bg-green-600 border-green-600',
|
||||
error: 'bg-red-600 border-red-600',
|
||||
manual: 'bg-orange-500 border-orange-500',
|
||||
all: "bg-blue-600 border-blue-600",
|
||||
pending: "bg-gray-500 border-gray-500",
|
||||
needs_action: "bg-gray-500 border-gray-500",
|
||||
noop: "bg-gray-500 border-gray-500",
|
||||
not_extracted: "bg-gray-500 border-gray-500",
|
||||
no_subs: "bg-gray-500 border-gray-500",
|
||||
skipped: "bg-gray-500 border-gray-500",
|
||||
running: "bg-amber-500 border-amber-500",
|
||||
done: "bg-green-600 border-green-600",
|
||||
approved: "bg-green-600 border-green-600",
|
||||
extracted: "bg-green-600 border-green-600",
|
||||
keep: "bg-green-600 border-green-600",
|
||||
error: "bg-red-600 border-red-600",
|
||||
manual: "bg-orange-500 border-orange-500",
|
||||
};
|
||||
|
||||
export function FilterTabs({ tabs, filter, totalCounts, onFilterChange }: FilterTabsProps) {
|
||||
@@ -32,16 +32,21 @@ export function FilterTabs({ tabs, filter, totalCounts, onFilterChange }: Filter
|
||||
<div className="flex gap-1 flex-wrap mb-3 items-center">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = filter === tab.key;
|
||||
const activeColor = ACTIVE_COLORS[tab.key] ?? 'bg-blue-600 border-blue-600';
|
||||
const activeColor = ACTIVE_COLORS[tab.key] ?? "bg-blue-600 border-blue-600";
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => onFilterChange(tab.key)}
|
||||
className={`px-2.5 py-0.5 rounded text-[0.8rem] border cursor-pointer transition-colors leading-[1.4] ${isActive ? `${activeColor} text-white` : 'border-gray-200 bg-transparent text-gray-500 hover:bg-gray-50'}`}
|
||||
className={`px-2.5 py-0.5 rounded text-[0.8rem] border cursor-pointer transition-colors leading-[1.4] ${isActive ? `${activeColor} text-white` : "border-gray-200 bg-transparent text-gray-500 hover:bg-gray-50"}`}
|
||||
>
|
||||
{tab.label}
|
||||
{totalCounts[tab.key] != null && <> <span className="text-[0.72rem] font-bold">{totalCounts[tab.key]}</span></>}
|
||||
{totalCounts[tab.key] != null && (
|
||||
<>
|
||||
{" "}
|
||||
<span className="text-[0.72rem] font-bold">{totalCounts[tab.key]}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type React from 'react';
|
||||
import { cn } from '~/shared/lib/utils';
|
||||
import type React from "react";
|
||||
import { cn } from "~/shared/lib/utils";
|
||||
|
||||
export function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
'border border-gray-300 rounded px-2.5 py-1.5 text-sm bg-white w-full',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
|
||||
'disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed',
|
||||
"border border-gray-300 rounded px-2.5 py-1.5 text-sm bg-white w-full",
|
||||
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent",
|
||||
"disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type React from 'react';
|
||||
import { cn } from '~/shared/lib/utils';
|
||||
import type React from "react";
|
||||
import { cn } from "~/shared/lib/utils";
|
||||
|
||||
export function Select({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
<select
|
||||
className={cn(
|
||||
'border border-gray-300 rounded px-2 py-1.5 text-sm bg-white',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
|
||||
'disabled:bg-gray-100 disabled:cursor-not-allowed',
|
||||
"border border-gray-300 rounded px-2 py-1.5 text-sm bg-white",
|
||||
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent",
|
||||
"disabled:bg-gray-100 disabled:cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type React from 'react';
|
||||
import { cn } from '~/shared/lib/utils';
|
||||
import type React from "react";
|
||||
import { cn } from "~/shared/lib/utils";
|
||||
|
||||
export function Textarea({ className, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'border border-gray-300 rounded px-2.5 py-1.5 text-sm bg-white w-full resize-vertical',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
|
||||
"border border-gray-300 rounded px-2.5 py-1.5 text-sm bg-white w-full resize-vertical",
|
||||
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/** Base URL for API calls. In dev Vite proxies /api → :3000. */
|
||||
const BASE = '';
|
||||
const BASE = "";
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(BASE + path, {
|
||||
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
|
||||
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
||||
...init,
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -16,11 +16,10 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined }),
|
||||
request<T>(path, { method: "POST", body: body !== undefined ? JSON.stringify(body) : undefined }),
|
||||
patch: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'PATCH', body: body !== undefined ? JSON.stringify(body) : undefined }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
request<T>(path, { method: "PATCH", body: body !== undefined ? JSON.stringify(body) : undefined }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||
/** POST multipart/form-data (file upload). Omit Content-Type so browser sets boundary. */
|
||||
postForm: <T>(path: string, body: FormData) =>
|
||||
request<T>(path, { method: 'POST', body, headers: {} }),
|
||||
postForm: <T>(path: string, body: FormData) => request<T>(path, { method: "POST", body, headers: {} }),
|
||||
};
|
||||
|
||||
@@ -1,18 +1,53 @@
|
||||
export const LANG_NAMES: Record<string, string> = {
|
||||
eng: 'English', deu: 'German', spa: 'Spanish', fra: 'French', ita: 'Italian',
|
||||
por: 'Portuguese', jpn: 'Japanese', kor: 'Korean', zho: 'Chinese', ara: 'Arabic',
|
||||
rus: 'Russian', nld: 'Dutch', swe: 'Swedish', nor: 'Norwegian', dan: 'Danish',
|
||||
fin: 'Finnish', pol: 'Polish', tur: 'Turkish', tha: 'Thai', hin: 'Hindi',
|
||||
hun: 'Hungarian', ces: 'Czech', ron: 'Romanian', ell: 'Greek', heb: 'Hebrew',
|
||||
fas: 'Persian', ukr: 'Ukrainian', ind: 'Indonesian', msa: 'Malay', vie: 'Vietnamese',
|
||||
cat: 'Catalan', tam: 'Tamil', tel: 'Telugu', slk: 'Slovak', hrv: 'Croatian',
|
||||
bul: 'Bulgarian', srp: 'Serbian', slv: 'Slovenian', lav: 'Latvian', lit: 'Lithuanian',
|
||||
est: 'Estonian', isl: 'Icelandic', nob: 'Norwegian Bokmål', nno: 'Norwegian Nynorsk',
|
||||
eng: "English",
|
||||
deu: "German",
|
||||
spa: "Spanish",
|
||||
fra: "French",
|
||||
ita: "Italian",
|
||||
por: "Portuguese",
|
||||
jpn: "Japanese",
|
||||
kor: "Korean",
|
||||
zho: "Chinese",
|
||||
ara: "Arabic",
|
||||
rus: "Russian",
|
||||
nld: "Dutch",
|
||||
swe: "Swedish",
|
||||
nor: "Norwegian",
|
||||
dan: "Danish",
|
||||
fin: "Finnish",
|
||||
pol: "Polish",
|
||||
tur: "Turkish",
|
||||
tha: "Thai",
|
||||
hin: "Hindi",
|
||||
hun: "Hungarian",
|
||||
ces: "Czech",
|
||||
ron: "Romanian",
|
||||
ell: "Greek",
|
||||
heb: "Hebrew",
|
||||
fas: "Persian",
|
||||
ukr: "Ukrainian",
|
||||
ind: "Indonesian",
|
||||
msa: "Malay",
|
||||
vie: "Vietnamese",
|
||||
cat: "Catalan",
|
||||
tam: "Tamil",
|
||||
tel: "Telugu",
|
||||
slk: "Slovak",
|
||||
hrv: "Croatian",
|
||||
bul: "Bulgarian",
|
||||
srp: "Serbian",
|
||||
slv: "Slovenian",
|
||||
lav: "Latvian",
|
||||
lit: "Lithuanian",
|
||||
est: "Estonian",
|
||||
isl: "Icelandic",
|
||||
nob: "Norwegian Bokmål",
|
||||
nno: "Norwegian Nynorsk",
|
||||
};
|
||||
|
||||
export const KNOWN_LANG_NAMES = new Set(Object.values(LANG_NAMES).map((n) => n.toLowerCase()));
|
||||
|
||||
export function langName(code: string | null | undefined): string {
|
||||
if (!code) return '—';
|
||||
if (!code) return "—";
|
||||
return LANG_NAMES[code.toLowerCase()] ?? code.toUpperCase();
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
export interface MediaItem {
|
||||
id: number;
|
||||
jellyfin_id: string;
|
||||
type: 'Movie' | 'Episode';
|
||||
type: "Movie" | "Episode";
|
||||
name: string;
|
||||
series_name: string | null;
|
||||
series_jellyfin_id: string | null;
|
||||
@@ -46,9 +46,9 @@ export interface ReviewPlan {
|
||||
item_id: number;
|
||||
status: string;
|
||||
is_noop: number;
|
||||
confidence: 'high' | 'low';
|
||||
apple_compat: 'direct_play' | 'remux' | 'audio_transcode' | null;
|
||||
job_type: 'copy' | 'transcode';
|
||||
confidence: "high" | "low";
|
||||
apple_compat: "direct_play" | "remux" | "audio_transcode" | null;
|
||||
job_type: "copy" | "transcode";
|
||||
subs_extracted: number;
|
||||
notes: string | null;
|
||||
reviewed_at: string | null;
|
||||
@@ -71,7 +71,7 @@ export interface StreamDecision {
|
||||
id: number;
|
||||
plan_id: number;
|
||||
stream_id: number;
|
||||
action: 'keep' | 'remove';
|
||||
action: "keep" | "remove";
|
||||
target_index: number | null;
|
||||
custom_title: string | null;
|
||||
transcode_codec: string | null;
|
||||
@@ -81,8 +81,8 @@ export interface Job {
|
||||
id: number;
|
||||
item_id: number;
|
||||
command: string;
|
||||
job_type: 'copy' | 'transcode';
|
||||
status: 'pending' | 'running' | 'done' | 'error';
|
||||
job_type: "copy" | "transcode";
|
||||
status: "pending" | "running" | "done" | "error";
|
||||
output: string | null;
|
||||
exit_code: number | null;
|
||||
created_at: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
|
||||
import { resolve } from 'node:path';
|
||||
import pkg from './package.json' with { type: 'json' };
|
||||
import { resolve } from "node:path";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import { defineConfig } from "vite";
|
||||
import pkg from "./package.json" with { type: "json" };
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
TanStackRouterVite({ target: 'react', autoCodeSplitting: true }),
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
plugins: [TanStackRouterVite({ target: "react", autoCodeSplitting: true }), react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': resolve(__dirname, 'src'),
|
||||
"~": resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:3000', changeOrigin: true },
|
||||
"/api": { target: "http://localhost:3000", changeOrigin: true },
|
||||
},
|
||||
},
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user