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 },
|
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
|
||||||
"organizeImports": { "enabled": true },
|
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentStyle": "tab",
|
"indentStyle": "tab",
|
||||||
@@ -12,11 +12,26 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"suspicious": { "noExplicitAny": "off" },
|
"suspicious": {
|
||||||
"style": { "noNonNullAssertion": "off" }
|
"noExplicitAny": "off",
|
||||||
|
"noArrayIndexKey": "off"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"noNonNullAssertion": "off"
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"useExhaustiveDependencies": "off",
|
||||||
|
"noInvalidUseBeforeDeclaration": "off"
|
||||||
|
},
|
||||||
|
"a11y": {
|
||||||
|
"useButtonType": "off",
|
||||||
|
"noLabelWithoutControl": "off",
|
||||||
|
"noStaticElementInteractions": "off",
|
||||||
|
"useKeyWithClickEvents": "off"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": {
|
"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 { Hono } from "hono";
|
||||||
import { getDb, getConfig } from '../db/index';
|
import { getConfig, getDb } from "../db/index";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
app.get('/', (c) => {
|
app.get("/", (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
const totalItems = (db.prepare('SELECT COUNT(*) as n FROM media_items').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 scanned = (
|
||||||
const needsAction = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }).n;
|
db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'scanned'").get() as { n: number }
|
||||||
const noChange = (db.prepare('SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1').get() as { n: number }).n;
|
).n;
|
||||||
const approved = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").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 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 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 scanRunning = getConfig("scan_running") === "1";
|
||||||
const setupComplete = getConfig('setup_complete') === '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;
|
export default app;
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { Hono } from 'hono';
|
import { accessSync, constants } from "node:fs";
|
||||||
import { stream } from 'hono/streaming';
|
import { Hono } from "hono";
|
||||||
import { getDb } from '../db/index';
|
import { stream } from "hono/streaming";
|
||||||
import type { Job, MediaItem, MediaStream } from '../types';
|
import { getDb } from "../db/index";
|
||||||
import { predictExtractedFiles } from '../services/ffmpeg';
|
import { log, error as logError } from "../lib/log";
|
||||||
import { accessSync, constants } from 'node:fs';
|
import { predictExtractedFiles } from "../services/ffmpeg";
|
||||||
import { log, error as logError } from '../lib/log';
|
import {
|
||||||
import { getSchedulerState, updateSchedulerState } from '../services/scheduler';
|
getSchedulerState,
|
||||||
|
isInScheduleWindow,
|
||||||
|
msUntilWindow,
|
||||||
|
nextWindowTime,
|
||||||
|
sleepBetweenJobs,
|
||||||
|
updateSchedulerState,
|
||||||
|
waitForWindow,
|
||||||
|
} from "../services/scheduler";
|
||||||
|
import type { Job, MediaItem, MediaStream } from "../types";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -13,17 +21,45 @@ const app = new Hono();
|
|||||||
|
|
||||||
let queueRunning = false;
|
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> {
|
async function runSequential(jobs: Job[]): Promise<void> {
|
||||||
if (queueRunning) return;
|
if (queueRunning) return;
|
||||||
queueRunning = true;
|
queueRunning = true;
|
||||||
try {
|
try {
|
||||||
|
let first = true;
|
||||||
for (const job of jobs) {
|
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
|
// Atomic claim: only pick up jobs still pending
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const claimed = db
|
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);
|
.run(job.id);
|
||||||
if (claimed.changes === 0) continue; // cancelled or already running
|
if (claimed.changes === 0) continue; // cancelled or already running
|
||||||
|
emitQueueStatus("running");
|
||||||
try {
|
try {
|
||||||
await runJob(job);
|
await runJob(job);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -32,6 +68,7 @@ async function runSequential(jobs: Job[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
queueRunning = false;
|
queueRunning = false;
|
||||||
|
emitQueueStatus("idle");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,49 +96,89 @@ function parseFFmpegDuration(line: string): number | null {
|
|||||||
|
|
||||||
function loadJobRow(jobId: number) {
|
function loadJobRow(jobId: number) {
|
||||||
const db = getDb();
|
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,
|
SELECT j.*, mi.id as mi_id, mi.name, mi.type, mi.series_name, mi.season_number,
|
||||||
mi.episode_number, mi.file_path
|
mi.episode_number, mi.file_path
|
||||||
FROM jobs j
|
FROM jobs j
|
||||||
LEFT JOIN media_items mi ON mi.id = j.item_id
|
LEFT JOIN media_items mi ON mi.id = j.item_id
|
||||||
WHERE j.id = ?
|
WHERE j.id = ?
|
||||||
`).get(jobId) as (Job & {
|
`)
|
||||||
mi_id: number | null; name: string | null; type: string | null;
|
.get(jobId) as
|
||||||
series_name: string | null; season_number: number | null; episode_number: number | null;
|
| (Job & {
|
||||||
file_path: string | null;
|
mi_id: number | null;
|
||||||
}) | undefined;
|
name: string | null;
|
||||||
|
type: string | null;
|
||||||
|
series_name: string | null;
|
||||||
|
season_number: number | null;
|
||||||
|
episode_number: number | null;
|
||||||
|
file_path: string | null;
|
||||||
|
})
|
||||||
|
| undefined;
|
||||||
|
|
||||||
if (!row) return null;
|
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 };
|
return { job: row as unknown as Job, item };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── List ─────────────────────────────────────────────────────────────────────
|
// ─── List ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/', (c) => {
|
app.get("/", (c) => {
|
||||||
const db = getDb();
|
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 validFilters = ["all", "pending", "running", "done", "error"];
|
||||||
const whereClause = validFilters.includes(filter) && filter !== 'all' ? `WHERE j.status = ?` : '';
|
const whereClause = validFilters.includes(filter) && filter !== "all" ? `WHERE j.status = ?` : "";
|
||||||
const params = whereClause ? [filter] : [];
|
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
|
SELECT j.*, mi.name, mi.type, mi.series_name, mi.season_number, mi.episode_number, mi.file_path
|
||||||
FROM jobs j
|
FROM jobs j
|
||||||
LEFT JOIN media_items mi ON mi.id = j.item_id
|
LEFT JOIN media_items mi ON mi.id = j.item_id
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY j.created_at DESC
|
ORDER BY j.created_at DESC
|
||||||
LIMIT 200
|
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) => ({
|
const jobs = jobRows.map((r) => ({
|
||||||
job: r as unknown as Job,
|
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 };
|
const totalCounts: Record<string, number> = { all: 0, pending: 0, running: 0, done: 0, error: 0 };
|
||||||
for (const row of countRows) {
|
for (const row of countRows) {
|
||||||
totalCounts[row.status] = row.cnt;
|
totalCounts[row.status] = row.cnt;
|
||||||
@@ -121,22 +198,22 @@ function parseId(raw: string | undefined): number | null {
|
|||||||
|
|
||||||
// ─── Start all pending ────────────────────────────────────────────────────────
|
// ─── Start all pending ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/start', (c) => {
|
app.post("/start", (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const pending = db.prepare("SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at").all() as Job[];
|
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 });
|
return c.json({ ok: true, started: pending.length });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Run single ───────────────────────────────────────────────────────────────
|
// ─── Run single ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/job/:id/run', async (c) => {
|
app.post("/job/:id/run", async (c) => {
|
||||||
const jobId = parseId(c.req.param('id'));
|
const jobId = parseId(c.req.param("id"));
|
||||||
if (jobId == null) return c.json({ error: 'invalid job id' }, 400);
|
if (jobId == null) return c.json({ error: "invalid job id" }, 400);
|
||||||
const db = getDb();
|
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) return c.notFound();
|
||||||
if (job.status !== 'pending') {
|
if (job.status !== "pending") {
|
||||||
const result = loadJobRow(jobId);
|
const result = loadJobRow(jobId);
|
||||||
if (!result) return c.notFound();
|
if (!result) return c.notFound();
|
||||||
return c.json(result);
|
return c.json(result);
|
||||||
@@ -149,9 +226,9 @@ app.post('/job/:id/run', async (c) => {
|
|||||||
|
|
||||||
// ─── Cancel ───────────────────────────────────────────────────────────────────
|
// ─── Cancel ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/job/:id/cancel', (c) => {
|
app.post("/job/:id/cancel", (c) => {
|
||||||
const jobId = parseId(c.req.param('id'));
|
const jobId = parseId(c.req.param("id"));
|
||||||
if (jobId == null) return c.json({ error: 'invalid job id' }, 400);
|
if (jobId == null) return c.json({ error: "invalid job id" }, 400);
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
db.prepare("DELETE FROM jobs WHERE id = ? AND status = 'pending'").run(jobId);
|
db.prepare("DELETE FROM jobs WHERE id = ? AND status = 'pending'").run(jobId);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
@@ -159,18 +236,20 @@ app.post('/job/:id/cancel', (c) => {
|
|||||||
|
|
||||||
// ─── Clear queue ──────────────────────────────────────────────────────────────
|
// ─── Clear queue ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/clear', (c) => {
|
app.post("/clear", (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
db.prepare(`
|
db
|
||||||
|
.prepare(`
|
||||||
UPDATE review_plans SET status = 'pending', reviewed_at = NULL
|
UPDATE review_plans SET status = 'pending', reviewed_at = NULL
|
||||||
WHERE item_id IN (SELECT item_id FROM jobs WHERE status = 'pending')
|
WHERE item_id IN (SELECT item_id FROM jobs WHERE status = 'pending')
|
||||||
AND status = 'approved'
|
AND status = 'approved'
|
||||||
`).run();
|
`)
|
||||||
|
.run();
|
||||||
const result = db.prepare("DELETE FROM jobs WHERE status = 'pending'").run();
|
const result = db.prepare("DELETE FROM jobs WHERE status = 'pending'").run();
|
||||||
return c.json({ ok: true, cleared: result.changes });
|
return c.json({ ok: true, cleared: result.changes });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/clear-completed', (c) => {
|
app.post("/clear-completed", (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const result = db.prepare("DELETE FROM jobs WHERE status IN ('done', 'error')").run();
|
const result = db.prepare("DELETE FROM jobs WHERE status IN ('done', 'error')").run();
|
||||||
return c.json({ ok: true, cleared: result.changes });
|
return c.json({ ok: true, cleared: result.changes });
|
||||||
@@ -178,26 +257,34 @@ app.post('/clear-completed', (c) => {
|
|||||||
|
|
||||||
// ─── SSE ──────────────────────────────────────────────────────────────────────
|
// ─── SSE ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/events', (c) => {
|
app.get("/events", (c) => {
|
||||||
return stream(c, async (s) => {
|
return stream(c, async (s) => {
|
||||||
c.header('Content-Type', 'text/event-stream');
|
c.header("Content-Type", "text/event-stream");
|
||||||
c.header('Cache-Control', 'no-cache');
|
c.header("Cache-Control", "no-cache");
|
||||||
|
|
||||||
const queue: string[] = [];
|
const queue: string[] = [];
|
||||||
let resolve: (() => void) | null = null;
|
let resolve: (() => void) | null = null;
|
||||||
const listener = (data: string) => { queue.push(data); resolve?.(); };
|
const listener = (data: string) => {
|
||||||
|
queue.push(data);
|
||||||
|
resolve?.();
|
||||||
|
};
|
||||||
|
|
||||||
jobListeners.add(listener);
|
jobListeners.add(listener);
|
||||||
s.onAbort(() => { jobListeners.delete(listener); });
|
s.onAbort(() => {
|
||||||
|
jobListeners.delete(listener);
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (!s.closed) {
|
while (!s.closed) {
|
||||||
if (queue.length > 0) {
|
if (queue.length > 0) {
|
||||||
await s.write(queue.shift()!);
|
await s.write(queue.shift()!);
|
||||||
} else {
|
} 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;
|
resolve = null;
|
||||||
if (queue.length === 0) await s.write(': keepalive\n\n');
|
if (queue.length === 0) await s.write(": keepalive\n\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -213,30 +300,34 @@ async function runJob(job: Job): Promise<void> {
|
|||||||
log(`Job ${job.id} command: ${job.command}`);
|
log(`Job ${job.id} command: ${job.command}`);
|
||||||
const db = getDb();
|
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) {
|
if (itemRow?.file_path) {
|
||||||
try {
|
try {
|
||||||
accessSync(itemRow.file_path, constants.R_OK | constants.W_OK);
|
accessSync(itemRow.file_path, constants.R_OK | constants.W_OK);
|
||||||
} catch (fsErr) {
|
} catch (fsErr) {
|
||||||
const msg = `File not accessible: ${itemRow.file_path}\n${(fsErr as Error).message}`;
|
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);
|
db
|
||||||
emitJobUpdate(job.id, 'error', msg);
|
.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);
|
db.prepare("UPDATE review_plans SET status = 'error' WHERE item_id = ?").run(job.item_id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emitJobUpdate(job.id, 'running');
|
emitJobUpdate(job.id, "running");
|
||||||
|
|
||||||
const outputLines: string[] = [];
|
const outputLines: string[] = [];
|
||||||
let pendingFlush = false;
|
let pendingFlush = false;
|
||||||
let lastFlushAt = 0;
|
let lastFlushAt = 0;
|
||||||
let totalSeconds = 0;
|
let totalSeconds = 0;
|
||||||
let lastProgressEmit = 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 flush = (final = false) => {
|
||||||
const text = outputLines.join('\n');
|
const text = outputLines.join("\n");
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (final || now - lastFlushAt > 500) {
|
if (final || now - lastFlushAt > 500) {
|
||||||
updateOutput.run(text, job.id);
|
updateOutput.run(text, job.id);
|
||||||
@@ -245,7 +336,7 @@ async function runJob(job: Job): Promise<void> {
|
|||||||
} else {
|
} else {
|
||||||
pendingFlush = true;
|
pendingFlush = true;
|
||||||
}
|
}
|
||||||
emitJobUpdate(job.id, 'running', text);
|
emitJobUpdate(job.id, "running", text);
|
||||||
};
|
};
|
||||||
|
|
||||||
const consumeProgress = (line: string) => {
|
const consumeProgress = (line: string) => {
|
||||||
@@ -264,18 +355,18 @@ async function runJob(job: Job): Promise<void> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(['sh', '-c', job.command], { stdout: 'pipe', stderr: 'pipe' });
|
const proc = Bun.spawn(["sh", "-c", job.command], { stdout: "pipe", stderr: "pipe" });
|
||||||
const readStream = async (readable: ReadableStream<Uint8Array>, prefix = '') => {
|
const readStream = async (readable: ReadableStream<Uint8Array>, prefix = "") => {
|
||||||
const reader = readable.getReader();
|
const reader = readable.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = '';
|
let buffer = "";
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
buffer += decoder.decode(value, { stream: true });
|
buffer += decoder.decode(value, { stream: true });
|
||||||
const parts = buffer.split(/\r\n|\n|\r/);
|
const parts = buffer.split(/\r\n|\n|\r/);
|
||||||
buffer = parts.pop() ?? '';
|
buffer = parts.pop() ?? "";
|
||||||
for (const line of parts) {
|
for (const line of parts) {
|
||||||
if (!line.trim()) continue;
|
if (!line.trim()) continue;
|
||||||
outputLines.push(prefix + line);
|
outputLines.push(prefix + line);
|
||||||
@@ -288,25 +379,29 @@ async function runJob(job: Job): Promise<void> {
|
|||||||
consumeProgress(buffer);
|
consumeProgress(buffer);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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;
|
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}`);
|
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
|
// Gather sidecar files to record
|
||||||
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(job.item_id) as MediaItem | undefined;
|
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 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 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 insertFile = db.prepare(
|
||||||
const markJobDone = db.prepare("UPDATE jobs SET status = 'done', exit_code = 0, output = ?, completed_at = datetime('now') WHERE id = ?");
|
"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 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(() => {
|
db.transaction(() => {
|
||||||
markJobDone.run(fullOutput, job.id);
|
markJobDone.run(fullOutput, job.id);
|
||||||
@@ -318,23 +413,25 @@ async function runJob(job: Job): Promise<void> {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
log(`Job ${job.id} completed successfully`);
|
log(`Job ${job.id} completed successfully`);
|
||||||
emitJobUpdate(job.id, 'done', fullOutput);
|
emitJobUpdate(job.id, "done", fullOutput);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(`Job ${job.id} failed:`, err);
|
logError(`Job ${job.id} failed:`, err);
|
||||||
const fullOutput = outputLines.join('\n') + '\n' + String(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);
|
db
|
||||||
emitJobUpdate(job.id, 'error', fullOutput);
|
.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);
|
db.prepare("UPDATE review_plans SET status = 'error' WHERE item_id = ?").run(job.item_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Scheduler ────────────────────────────────────────────────────────────────
|
// ─── Scheduler ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/scheduler', (c) => {
|
app.get("/scheduler", (c) => {
|
||||||
return c.json(getSchedulerState());
|
return c.json(getSchedulerState());
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch('/scheduler', async (c) => {
|
app.patch("/scheduler", async (c) => {
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
updateSchedulerState(body);
|
updateSchedulerState(body);
|
||||||
return c.json(getSchedulerState());
|
return c.json(getSchedulerState());
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from "node:fs";
|
||||||
import { Hono } from 'hono';
|
import { Hono } from "hono";
|
||||||
import { getDb } from '../db/index';
|
import { getDb } from "../db/index";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ interface PathInfo {
|
|||||||
accessible: boolean;
|
accessible: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/', (c) => {
|
app.get("/", (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const rows = db
|
const rows = db
|
||||||
.query<{ prefix: string; count: number }, []>(
|
.query<{ prefix: string; count: number }, []>(
|
||||||
|
|||||||
@@ -1,62 +1,96 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from "hono";
|
||||||
import { getDb, getConfig, getAllConfig } from '../db/index';
|
import { getAllConfig, getConfig, getDb } from "../db/index";
|
||||||
import { analyzeItem, assignTargetOrder } from '../services/analyzer';
|
import { isOneOf, parseId } from "../lib/validate";
|
||||||
import { buildCommand } from '../services/ffmpeg';
|
import { analyzeItem, assignTargetOrder } from "../services/analyzer";
|
||||||
import { normalizeLanguage, getItem, refreshItem, mapStream } from '../services/jellyfin';
|
import { buildCommand } from "../services/ffmpeg";
|
||||||
import { parseId, isOneOf } from '../lib/validate';
|
import { getItem, mapStream, normalizeLanguage, refreshItem } from "../services/jellyfin";
|
||||||
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '../types';
|
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from "../types";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getSubtitleLanguages(): string[] {
|
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> {
|
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 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 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 pending = (
|
||||||
const approved = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").get() as { n: number }).n;
|
db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }
|
||||||
const skipped = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'skipped'").get() as { n: number }).n;
|
).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 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 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 };
|
return { all: total, needs_action: pending, noop: noops, approved, skipped, done, error, manual };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWhereClause(filter: string): string {
|
function buildWhereClause(filter: string): string {
|
||||||
switch (filter) {
|
switch (filter) {
|
||||||
case 'needs_action': return "rp.status = 'pending' AND rp.is_noop = 0";
|
case "needs_action":
|
||||||
case 'noop': return 'rp.is_noop = 1';
|
return "rp.status = 'pending' AND rp.is_noop = 0";
|
||||||
case 'manual': return 'mi.needs_review = 1 AND mi.original_language IS NULL';
|
case "noop":
|
||||||
case 'approved': return "rp.status = 'approved'";
|
return "rp.is_noop = 1";
|
||||||
case 'skipped': return "rp.status = 'skipped'";
|
case "manual":
|
||||||
case 'done': return "rp.status = 'done'";
|
return "mi.needs_review = 1 AND mi.original_language IS NULL";
|
||||||
case 'error': return "rp.status = 'error'";
|
case "approved":
|
||||||
default: return '1=1';
|
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 & {
|
type RawRow = MediaItem & {
|
||||||
plan_id: number | null; plan_status: string | null; is_noop: number | null;
|
plan_id: number | null;
|
||||||
plan_notes: string | null; reviewed_at: string | null; plan_created_at: string | null;
|
plan_status: string | null;
|
||||||
remove_count: number; keep_count: number;
|
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 {
|
function rowToPlan(r: RawRow): ReviewPlan | null {
|
||||||
if (r.plan_id == null) return 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) {
|
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 };
|
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 streams = db
|
||||||
const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(itemId) as ReviewPlan | undefined | null;
|
.prepare("SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index")
|
||||||
const decisions = plan ? db.prepare('SELECT * FROM stream_decisions WHERE plan_id = ?').all(plan.id) as StreamDecision[] : [];
|
.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;
|
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.
|
* survive stream-id changes when Jellyfin re-probes metadata.
|
||||||
*/
|
*/
|
||||||
function titleKey(s: { type: string; language: string | null; stream_index: number; title: string | null }): string {
|
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 {
|
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;
|
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 subtitleLanguages = getSubtitleLanguages();
|
||||||
const audioLanguages: string[] = JSON.parse(getConfig('audio_languages') ?? '[]');
|
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 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)
|
INSERT INTO review_plans (item_id, status, is_noop, confidence, apple_compat, job_type, notes)
|
||||||
VALUES (?, 'pending', ?, ?, ?, ?, ?)
|
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
|
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);
|
// Preserve existing custom_titles: prefer by stream_id (streams unchanged);
|
||||||
// fall back to titleKey match (streams regenerated after rescan).
|
// fall back to titleKey match (streams regenerated after rescan).
|
||||||
const byStreamId = new Map<number, string | null>(
|
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);
|
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 (?, ?, ?, ?, ?, ?)');
|
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) {
|
for (const dec of analysis.decisions) {
|
||||||
let customTitle = byStreamId.get(dec.stream_id) ?? null;
|
let customTitle = byStreamId.get(dec.stream_id) ?? null;
|
||||||
if (!customTitle && preservedTitles) {
|
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.
|
* recompute is_noop without wiping user-chosen actions or custom_titles.
|
||||||
*/
|
*/
|
||||||
function recomputePlanAfterToggle(db: ReturnType<typeof getDb>, itemId: number): void {
|
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;
|
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
|
||||||
const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number } | undefined;
|
.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;
|
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 {
|
const decisions = db
|
||||||
stream_id: number; action: 'keep' | 'remove'; target_index: number | null; transcode_codec: string | null
|
.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 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
|
// 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);
|
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);
|
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
|
// 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 anyAudioRemoved = streams.some(
|
||||||
const hasSubs = streams.some(s => s.type === 'Subtitle');
|
(s) => s.type === "Audio" && decWithIdx.find((d) => d.stream_id === s.id)?.action === "remove",
|
||||||
const needsTranscode = decWithIdx.some(d => d.transcode_codec != null && d.action === 'keep');
|
);
|
||||||
|
const hasSubs = streams.some((s) => s.type === "Subtitle");
|
||||||
|
const needsTranscode = decWithIdx.some((d) => d.transcode_codec != null && d.action === "keep");
|
||||||
|
|
||||||
const keptAudio = streams
|
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);
|
.sort((a, b) => a.stream_index - b.stream_index);
|
||||||
let audioOrderChanged = false;
|
let audioOrderChanged = false;
|
||||||
for (let i = 0; i < keptAudio.length; i++) {
|
for (let i = 0; i < keptAudio.length; i++) {
|
||||||
const dec = decWithIdx.find(d => d.stream_id === keptAudio[i].id);
|
const dec = decWithIdx.find((d) => d.stream_id === keptAudio[i].id);
|
||||||
if (dec?.target_index !== i) { audioOrderChanged = true; break; }
|
if (dec?.target_index !== i) {
|
||||||
|
audioOrderChanged = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNoop = !anyAudioRemoved && !audioOrderChanged && !hasSubs && !needsTranscode;
|
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 ───────────────────────────────────────────────────────
|
// ─── Pipeline: summary ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/pipeline', (c) => {
|
app.get("/pipeline", (c) => {
|
||||||
const db = getDb();
|
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,
|
SELECT rp.*, mi.name, mi.series_name, mi.series_jellyfin_id,
|
||||||
mi.jellyfin_id,
|
mi.jellyfin_id,
|
||||||
mi.season_number, mi.episode_number, mi.type, mi.container,
|
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,
|
CASE rp.confidence WHEN 'high' THEN 0 ELSE 1 END,
|
||||||
COALESCE(mi.series_name, mi.name),
|
COALESCE(mi.series_name, mi.name),
|
||||||
mi.season_number, mi.episode_number
|
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,
|
SELECT j.*, mi.name, mi.series_name, mi.type,
|
||||||
rp.job_type, rp.apple_compat
|
rp.job_type, rp.apple_compat
|
||||||
FROM jobs j
|
FROM jobs j
|
||||||
@@ -179,18 +254,22 @@ app.get('/pipeline', (c) => {
|
|||||||
JOIN review_plans rp ON rp.item_id = j.item_id
|
JOIN review_plans rp ON rp.item_id = j.item_id
|
||||||
WHERE j.status = 'pending'
|
WHERE j.status = 'pending'
|
||||||
ORDER BY j.created_at
|
ORDER BY j.created_at
|
||||||
`).all();
|
`)
|
||||||
|
.all();
|
||||||
|
|
||||||
const processing = db.prepare(`
|
const processing = db
|
||||||
|
.prepare(`
|
||||||
SELECT j.*, mi.name, mi.series_name, mi.type,
|
SELECT j.*, mi.name, mi.series_name, mi.type,
|
||||||
rp.job_type, rp.apple_compat
|
rp.job_type, rp.apple_compat
|
||||||
FROM jobs j
|
FROM jobs j
|
||||||
JOIN media_items mi ON mi.id = j.item_id
|
JOIN media_items mi ON mi.id = j.item_id
|
||||||
JOIN review_plans rp ON rp.item_id = j.item_id
|
JOIN review_plans rp ON rp.item_id = j.item_id
|
||||||
WHERE j.status = 'running'
|
WHERE j.status = 'running'
|
||||||
`).all();
|
`)
|
||||||
|
.all();
|
||||||
|
|
||||||
const done = db.prepare(`
|
const done = db
|
||||||
|
.prepare(`
|
||||||
SELECT j.*, mi.name, mi.series_name, mi.type,
|
SELECT j.*, mi.name, mi.series_name, mi.type,
|
||||||
rp.job_type, rp.apple_compat
|
rp.job_type, rp.apple_compat
|
||||||
FROM jobs j
|
FROM jobs j
|
||||||
@@ -199,24 +278,27 @@ app.get('/pipeline', (c) => {
|
|||||||
WHERE j.status IN ('done', 'error')
|
WHERE j.status IN ('done', 'error')
|
||||||
ORDER BY j.completed_at DESC
|
ORDER BY j.completed_at DESC
|
||||||
LIMIT 50
|
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)
|
// 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[]>();
|
const reasonsByPlan = new Map<number, string[]>();
|
||||||
if (planIds.length > 0) {
|
if (planIds.length > 0) {
|
||||||
const placeholders = planIds.map(() => '?').join(',');
|
const placeholders = planIds.map(() => "?").join(",");
|
||||||
const allReasons = db.prepare(`
|
const allReasons = db
|
||||||
|
.prepare(`
|
||||||
SELECT DISTINCT sd.plan_id, ms.codec, sd.transcode_codec
|
SELECT DISTINCT sd.plan_id, ms.codec, sd.transcode_codec
|
||||||
FROM stream_decisions sd
|
FROM stream_decisions sd
|
||||||
JOIN media_streams ms ON ms.id = sd.stream_id
|
JOIN media_streams ms ON ms.id = sd.stream_id
|
||||||
WHERE sd.plan_id IN (${placeholders}) AND sd.transcode_codec IS NOT NULL
|
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) {
|
for (const r of allReasons) {
|
||||||
if (!reasonsByPlan.has(r.plan_id)) reasonsByPlan.set(r.plan_id, []);
|
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[] }[]) {
|
for (const item of review as { id: number; transcode_reasons?: string[] }[]) {
|
||||||
@@ -228,12 +310,13 @@ app.get('/pipeline', (c) => {
|
|||||||
|
|
||||||
// ─── List ─────────────────────────────────────────────────────────────────────
|
// ─── List ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/', (c) => {
|
app.get("/", (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const filter = c.req.query('filter') ?? 'all';
|
const filter = c.req.query("filter") ?? "all";
|
||||||
const where = buildWhereClause(filter);
|
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,
|
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,
|
rp.reviewed_at, rp.created_at as plan_created_at,
|
||||||
COUNT(CASE WHEN sd.action = 'remove' THEN 1 END) as remove_count,
|
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
|
LEFT JOIN stream_decisions sd ON sd.plan_id = rp.id
|
||||||
WHERE mi.type = 'Movie' AND ${where}
|
WHERE mi.type = 'Movie' AND ${where}
|
||||||
GROUP BY mi.id ORDER BY mi.name LIMIT 500
|
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,
|
SELECT COALESCE(mi.series_jellyfin_id, mi.series_name) as series_key, mi.series_name,
|
||||||
MAX(mi.original_language) as original_language,
|
MAX(mi.original_language) as original_language,
|
||||||
COUNT(DISTINCT mi.season_number) as season_count, COUNT(mi.id) as episode_count,
|
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
|
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
||||||
WHERE mi.type = 'Episode' AND ${where}
|
WHERE mi.type = 'Episode' AND ${where}
|
||||||
GROUP BY series_key ORDER BY mi.series_name
|
GROUP BY series_key ORDER BY mi.series_name
|
||||||
`).all();
|
`)
|
||||||
|
.all();
|
||||||
|
|
||||||
const totalCounts = countsByFilter(db);
|
const totalCounts = countsByFilter(db);
|
||||||
return c.json({ movies, series, filter, totalCounts });
|
return c.json({ movies, series, filter, totalCounts });
|
||||||
@@ -270,11 +361,12 @@ app.get('/', (c) => {
|
|||||||
|
|
||||||
// ─── Series episodes ──────────────────────────────────────────────────────────
|
// ─── Series episodes ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/series/:seriesKey/episodes', (c) => {
|
app.get("/series/:seriesKey/episodes", (c) => {
|
||||||
const db = getDb();
|
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,
|
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,
|
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
|
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'
|
WHERE mi.type = 'Episode'
|
||||||
AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?))
|
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
|
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[]>();
|
const seasonMap = new Map<number | null, unknown[]>();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
@@ -299,9 +392,11 @@ app.get('/series/:seriesKey/episodes', (c) => {
|
|||||||
season,
|
season,
|
||||||
episodes,
|
episodes,
|
||||||
noopCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.is_noop).length,
|
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,
|
actionCount: (episodes as { plan: ReviewPlan | null }[]).filter(
|
||||||
approvedCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.status === 'approved').length,
|
(e) => e.plan?.status === "pending" && !e.plan.is_noop,
|
||||||
doneCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.status === 'done').length,
|
).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 });
|
return c.json({ seasons });
|
||||||
@@ -309,63 +404,78 @@ app.get('/series/:seriesKey/episodes', (c) => {
|
|||||||
|
|
||||||
// ─── Approve series ───────────────────────────────────────────────────────────
|
// ─── Approve series ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/series/:seriesKey/approve-all', (c) => {
|
app.post("/series/:seriesKey/approve-all", (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const seriesKey = decodeURIComponent(c.req.param('seriesKey'));
|
const seriesKey = decodeURIComponent(c.req.param("seriesKey"));
|
||||||
const pending = db.prepare(`
|
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
|
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 = ?))
|
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
|
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) {
|
for (const plan of pending) {
|
||||||
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
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);
|
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 });
|
return c.json({ ok: true, count: pending.length });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Approve season ───────────────────────────────────────────────────────────
|
// ─── Approve season ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/season/:seriesKey/:season/approve-all', (c) => {
|
app.post("/season/:seriesKey/:season/approve-all", (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const seriesKey = decodeURIComponent(c.req.param('seriesKey'));
|
const seriesKey = decodeURIComponent(c.req.param("seriesKey"));
|
||||||
const season = Number.parseInt(c.req.param('season') ?? '', 10);
|
const season = Number.parseInt(c.req.param("season") ?? "", 10);
|
||||||
if (!Number.isFinite(season)) return c.json({ error: 'invalid season' }, 400);
|
if (!Number.isFinite(season)) return c.json({ error: "invalid season" }, 400);
|
||||||
const pending = db.prepare(`
|
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
|
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 = ?))
|
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
|
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) {
|
for (const plan of pending) {
|
||||||
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
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);
|
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 });
|
return c.json({ ok: true, count: pending.length });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Approve all ──────────────────────────────────────────────────────────────
|
// ─── Approve all ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/approve-all', (c) => {
|
app.post("/approve-all", (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const pending = db.prepare(
|
const pending = db
|
||||||
"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"
|
.prepare(
|
||||||
).all() as (ReviewPlan & { item_id: number })[];
|
"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) {
|
for (const plan of pending) {
|
||||||
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
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);
|
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 });
|
return c.json({ ok: true, count: pending.length });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Detail ───────────────────────────────────────────────────────────────────
|
// ─── Detail ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/:id', (c) => {
|
app.get("/:id", (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = parseId(c.req.param('id'));
|
const id = parseId(c.req.param("id"));
|
||||||
if (id == null) return c.json({ error: 'invalid id' }, 400);
|
if (id == null) return c.json({ error: "invalid id" }, 400);
|
||||||
const detail = loadItemDetail(db, id);
|
const detail = loadItemDetail(db, id);
|
||||||
if (!detail.item) return c.notFound();
|
if (!detail.item) return c.notFound();
|
||||||
return c.json(detail);
|
return c.json(detail);
|
||||||
@@ -373,13 +483,14 @@ app.get('/:id', (c) => {
|
|||||||
|
|
||||||
// ─── Override language ────────────────────────────────────────────────────────
|
// ─── Override language ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.patch('/:id/language', async (c) => {
|
app.patch("/:id/language", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = parseId(c.req.param('id'));
|
const id = parseId(c.req.param("id"));
|
||||||
if (id == null) return c.json({ error: 'invalid id' }, 400);
|
if (id == null) return c.json({ error: "invalid id" }, 400);
|
||||||
const body = await c.req.json<{ language: string | null }>();
|
const body = await c.req.json<{ language: string | null }>();
|
||||||
const lang = body.language || 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);
|
.run(lang ? normalizeLanguage(lang) : null, id);
|
||||||
reanalyze(db, id);
|
reanalyze(db, id);
|
||||||
const detail = loadItemDetail(db, id);
|
const detail = loadItemDetail(db, id);
|
||||||
@@ -389,16 +500,18 @@ app.patch('/:id/language', async (c) => {
|
|||||||
|
|
||||||
// ─── Edit stream title ────────────────────────────────────────────────────────
|
// ─── Edit stream title ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.patch('/:id/stream/:streamId/title', async (c) => {
|
app.patch("/:id/stream/:streamId/title", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const itemId = parseId(c.req.param('id'));
|
const itemId = parseId(c.req.param("id"));
|
||||||
const streamId = parseId(c.req.param('streamId'));
|
const streamId = parseId(c.req.param("streamId"));
|
||||||
if (itemId == null || streamId == null) return c.json({ error: 'invalid id' }, 400);
|
if (itemId == null || streamId == null) return c.json({ error: "invalid id" }, 400);
|
||||||
const body = await c.req.json<{ title: string }>();
|
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();
|
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);
|
const detail = loadItemDetail(db, itemId);
|
||||||
if (!detail.item) return c.notFound();
|
if (!detail.item) return c.notFound();
|
||||||
return c.json(detail);
|
return c.json(detail);
|
||||||
@@ -406,26 +519,30 @@ app.patch('/:id/stream/:streamId/title', async (c) => {
|
|||||||
|
|
||||||
// ─── Toggle stream action ─────────────────────────────────────────────────────
|
// ─── Toggle stream action ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.patch('/:id/stream/:streamId', async (c) => {
|
app.patch("/:id/stream/:streamId", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const itemId = parseId(c.req.param('id'));
|
const itemId = parseId(c.req.param("id"));
|
||||||
const streamId = parseId(c.req.param('streamId'));
|
const streamId = parseId(c.req.param("streamId"));
|
||||||
if (itemId == null || streamId == null) return c.json({ error: 'invalid id' }, 400);
|
if (itemId == null || streamId == null) return c.json({ error: "invalid id" }, 400);
|
||||||
|
|
||||||
const body = await c.req.json<{ action: unknown }>().catch(() => ({ action: null }));
|
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);
|
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)
|
// 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;
|
const stream = db.prepare("SELECT type, item_id FROM media_streams WHERE id = ?").get(streamId) as
|
||||||
if (!stream || stream.item_id !== itemId) return c.json({ error: 'stream not found on item' }, 404);
|
| { type: string; item_id: number }
|
||||||
if (stream.type === 'Subtitle') return c.json({ error: 'Subtitle streams cannot be toggled' }, 400);
|
| 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();
|
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);
|
recomputePlanAfterToggle(db, itemId);
|
||||||
|
|
||||||
@@ -436,63 +553,94 @@ app.patch('/:id/stream/:streamId', async (c) => {
|
|||||||
|
|
||||||
// ─── Approve ──────────────────────────────────────────────────────────────────
|
// ─── Approve ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/:id/approve', (c) => {
|
app.post("/:id/approve", (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = parseId(c.req.param('id'));
|
const id = parseId(c.req.param("id"));
|
||||||
if (id == null) return c.json({ error: 'invalid id' }, 400);
|
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 plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(id) as ReviewPlan | undefined;
|
||||||
if (!plan) return c.notFound();
|
if (!plan) return c.notFound();
|
||||||
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
||||||
if (!plan.is_noop) {
|
if (!plan.is_noop) {
|
||||||
const { item, streams, decisions } = loadItemDetail(db, id);
|
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 });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Unapprove ───────────────────────────────────────────────────────────────
|
// ─── Unapprove ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/:id/unapprove', (c) => {
|
// ─── Retry failed job ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.post("/:id/retry", (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = parseId(c.req.param('id'));
|
const id = parseId(c.req.param("id"));
|
||||||
if (id == null) return c.json({ error: 'invalid id' }, 400);
|
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 plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(id) as ReviewPlan | undefined;
|
||||||
if (!plan) return c.notFound();
|
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
|
// 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;
|
const job = db.prepare("SELECT * FROM jobs WHERE item_id = ? ORDER BY created_at DESC LIMIT 1").get(id) as
|
||||||
if (job && job.status !== 'pending') return c.json({ ok: false, error: 'Job already started — cannot unapprove' }, 409);
|
| { 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
|
// 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);
|
db.prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE id = ?").run(plan.id);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Skip / Unskip ───────────────────────────────────────────────────────────
|
// ─── Skip / Unskip ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/:id/skip', (c) => {
|
app.post("/:id/skip", (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = parseId(c.req.param('id'));
|
const id = parseId(c.req.param("id"));
|
||||||
if (id == null) return c.json({ error: 'invalid id' }, 400);
|
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);
|
db.prepare("UPDATE review_plans SET status = 'skipped', reviewed_at = datetime('now') WHERE item_id = ?").run(id);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/:id/unskip', (c) => {
|
app.post("/:id/unskip", (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = parseId(c.req.param('id'));
|
const id = parseId(c.req.param("id"));
|
||||||
if (id == null) return c.json({ error: 'invalid id' }, 400);
|
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);
|
db
|
||||||
|
.prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE item_id = ? AND status = 'skipped'")
|
||||||
|
.run(id);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Rescan ───────────────────────────────────────────────────────────────────
|
// ─── Rescan ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/:id/rescan', async (c) => {
|
app.post("/:id/rescan", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = parseId(c.req.param('id'));
|
const id = parseId(c.req.param("id"));
|
||||||
if (id == null) return c.json({ error: 'invalid id' }, 400);
|
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();
|
if (!item) return c.notFound();
|
||||||
|
|
||||||
const cfg = getAllConfig();
|
const cfg = getAllConfig();
|
||||||
@@ -505,13 +653,21 @@ app.post('/:id/rescan', async (c) => {
|
|||||||
// Snapshot custom_titles keyed by stable properties, since replacing
|
// Snapshot custom_titles keyed by stable properties, since replacing
|
||||||
// media_streams cascades away all stream_decisions.
|
// media_streams cascades away all stream_decisions.
|
||||||
const preservedTitles = new Map<string, string>();
|
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
|
SELECT ms.type, ms.language, ms.stream_index, ms.title, sd.custom_title
|
||||||
FROM stream_decisions sd
|
FROM stream_decisions sd
|
||||||
JOIN media_streams ms ON ms.id = sd.stream_id
|
JOIN media_streams ms ON ms.id = sd.stream_id
|
||||||
JOIN review_plans rp ON rp.id = sd.plan_id
|
JOIN review_plans rp ON rp.id = sd.plan_id
|
||||||
WHERE rp.item_id = ? AND sd.custom_title IS NOT NULL
|
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) {
|
for (const r of oldRows) {
|
||||||
preservedTitles.set(titleKey(r), r.custom_title);
|
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)
|
title, is_default, is_forced, is_hearing_impaired, channels, channel_layout, bit_rate, sample_rate)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
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 ?? []) {
|
for (const jStream of fresh.MediaStreams ?? []) {
|
||||||
if (jStream.IsExternal) continue; // skip external subs — not embedded in container
|
if (jStream.IsExternal) continue; // skip external subs — not embedded in container
|
||||||
const s = mapStream(jStream);
|
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 ────────────────────────────────────────────
|
// ─── Pipeline: approve up to here ────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/approve-up-to/:id', (c) => {
|
app.post("/approve-up-to/:id", (c) => {
|
||||||
const targetId = parseId(c.req.param('id'));
|
const targetId = parseId(c.req.param("id"));
|
||||||
if (targetId == null) return c.json({ error: 'invalid id' }, 400);
|
if (targetId == null) return c.json({ error: "invalid id" }, 400);
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
const target = db.prepare('SELECT id FROM review_plans WHERE id = ?').get(targetId) as { id: number } | undefined;
|
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);
|
if (!target) return c.json({ error: "Plan not found" }, 404);
|
||||||
|
|
||||||
// Get all pending plans sorted by confidence (high first), then name
|
// Get all pending plans sorted by confidence (high first), then name
|
||||||
const pendingPlans = db.prepare(`
|
const pendingPlans = db
|
||||||
|
.prepare(`
|
||||||
SELECT rp.id
|
SELECT rp.id
|
||||||
FROM review_plans rp
|
FROM review_plans rp
|
||||||
JOIN media_items mi ON mi.id = rp.item_id
|
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.season_number,
|
||||||
mi.episode_number,
|
mi.episode_number,
|
||||||
mi.name
|
mi.name
|
||||||
`).all() as { id: number }[];
|
`)
|
||||||
|
.all() as { id: number }[];
|
||||||
|
|
||||||
// Find the target and approve everything up to and including it
|
// Find the target and approve everything up to and including it
|
||||||
const toApprove: number[] = [];
|
const toApprove: number[] = [];
|
||||||
@@ -571,10 +744,14 @@ app.post('/approve-up-to/:id', (c) => {
|
|||||||
// Batch approve and create jobs
|
// Batch approve and create jobs
|
||||||
for (const planId of toApprove) {
|
for (const planId of toApprove) {
|
||||||
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(planId);
|
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);
|
const detail = loadItemDetail(db, planRow.item_id);
|
||||||
if (detail.item && detail.command) {
|
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);
|
.run(planRow.item_id, detail.command, planRow.job_type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -584,18 +761,21 @@ app.post('/approve-up-to/:id', (c) => {
|
|||||||
|
|
||||||
// ─── Pipeline: series language ───────────────────────────────────────────────
|
// ─── Pipeline: series language ───────────────────────────────────────────────
|
||||||
|
|
||||||
app.patch('/series/:seriesKey/language', async (c) => {
|
app.patch("/series/:seriesKey/language", async (c) => {
|
||||||
const seriesKey = decodeURIComponent(c.req.param('seriesKey'));
|
const seriesKey = decodeURIComponent(c.req.param("seriesKey"));
|
||||||
const { language } = await c.req.json<{ language: string }>();
|
const { language } = await c.req.json<{ language: string }>();
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
const items = db.prepare(
|
const items = db
|
||||||
'SELECT id FROM media_items WHERE series_jellyfin_id = ? OR (series_jellyfin_id IS NULL AND series_name = ?)'
|
.prepare(
|
||||||
).all(seriesKey, seriesKey) as { id: number }[];
|
"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;
|
const normalizedLang = language ? normalizeLanguage(language) : null;
|
||||||
for (const item of items) {
|
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);
|
.run(normalizedLang, item.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from "hono";
|
||||||
import { stream } from 'hono/streaming';
|
import { stream } from "hono/streaming";
|
||||||
import { getDb, getConfig, setConfig, getAllConfig } from '../db/index';
|
import { getAllConfig, getConfig, getDb, setConfig } from "../db/index";
|
||||||
import { getAllItems, getDevItems, extractOriginalLanguage, mapStream, normalizeLanguage } from '../services/jellyfin';
|
import { log, error as logError, warn } from "../lib/log";
|
||||||
import { getOriginalLanguage as radarrLang } from '../services/radarr';
|
import { analyzeItem } from "../services/analyzer";
|
||||||
import { getOriginalLanguage as sonarrLang } from '../services/sonarr';
|
import { extractOriginalLanguage, getAllItems, getDevItems, mapStream, normalizeLanguage } from "../services/jellyfin";
|
||||||
import { analyzeItem } from '../services/analyzer';
|
import { getOriginalLanguage as radarrLang } from "../services/radarr";
|
||||||
import type { MediaItem, MediaStream } from '../types';
|
import { getOriginalLanguage as sonarrLang } from "../services/sonarr";
|
||||||
import { log, warn, error as logError } from '../lib/log';
|
import type { MediaStream } from "../types";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -21,45 +21,48 @@ function emitSse(type: string, data: unknown): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function currentScanLimit(): number | null {
|
function currentScanLimit(): number | null {
|
||||||
const v = getConfig('scan_limit');
|
const v = getConfig("scan_limit");
|
||||||
return v ? Number(v) : null;
|
return v ? Number(v) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Status ───────────────────────────────────────────────────────────────────
|
// ─── Status ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/', (c) => {
|
app.get("/", (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const running = getConfig('scan_running') === '1';
|
const running = getConfig("scan_running") === "1";
|
||||||
const total = (db.prepare('SELECT COUNT(*) as n FROM media_items').get() as { n: number }).n;
|
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 scanned = (
|
||||||
const errors = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'error'").get() as { n: number }).n;
|
db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'scanned'").get() as { n: number }
|
||||||
const recentItems = db.prepare(
|
).n;
|
||||||
'SELECT name, type, scan_status, file_path FROM media_items ORDER BY last_scanned_at DESC LIMIT 50'
|
const errors = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'error'").get() as { n: number })
|
||||||
).all() as { name: string; type: string; scan_status: string; file_path: string }[];
|
.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() });
|
return c.json({ running, progress: { scanned, total, errors }, recentItems, scanLimit: currentScanLimit() });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Start ────────────────────────────────────────────────────────────────────
|
// ─── Start ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/start', async (c) => {
|
app.post("/start", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
// Atomic claim: only succeed if scan_running is not already '1'.
|
// 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();
|
const claim = db.prepare("UPDATE config SET value = '1' WHERE key = 'scan_running' AND value != '1'").run();
|
||||||
if (claim.changes === 0) {
|
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 body = await c.req.json<{ limit?: number }>().catch(() => ({ limit: undefined }));
|
||||||
const formLimit = body.limit ?? null;
|
const formLimit = body.limit ?? null;
|
||||||
const envLimit = process.env.SCAN_LIMIT ? Number(process.env.SCAN_LIMIT) : null;
|
const envLimit = process.env.SCAN_LIMIT ? Number(process.env.SCAN_LIMIT) : null;
|
||||||
const limit = formLimit ?? envLimit ?? null;
|
const limit = formLimit ?? envLimit ?? null;
|
||||||
setConfig('scan_limit', limit != null ? String(limit) : '');
|
setConfig("scan_limit", limit != null ? String(limit) : "");
|
||||||
|
|
||||||
runScan(limit).catch((err) => {
|
runScan(limit).catch((err) => {
|
||||||
logError('Scan failed:', err);
|
logError("Scan failed:", err);
|
||||||
setConfig('scan_running', '0');
|
setConfig("scan_running", "0");
|
||||||
emitSse('error', { message: String(err) });
|
emitSse("error", { message: String(err) });
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
@@ -67,19 +70,19 @@ app.post('/start', async (c) => {
|
|||||||
|
|
||||||
// ─── Stop ─────────────────────────────────────────────────────────────────────
|
// ─── Stop ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/stop', (c) => {
|
app.post("/stop", (c) => {
|
||||||
scanAbort?.abort();
|
scanAbort?.abort();
|
||||||
setConfig('scan_running', '0');
|
setConfig("scan_running", "0");
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── SSE ──────────────────────────────────────────────────────────────────────
|
// ─── SSE ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/events', (c) => {
|
app.get("/events", (c) => {
|
||||||
return stream(c, async (s) => {
|
return stream(c, async (s) => {
|
||||||
c.header('Content-Type', 'text/event-stream');
|
c.header("Content-Type", "text/event-stream");
|
||||||
c.header('Cache-Control', 'no-cache');
|
c.header("Cache-Control", "no-cache");
|
||||||
c.header('Connection', 'keep-alive');
|
c.header("Connection", "keep-alive");
|
||||||
|
|
||||||
const queue: string[] = [];
|
const queue: string[] = [];
|
||||||
let resolve: (() => void) | null = null;
|
let resolve: (() => void) | null = null;
|
||||||
@@ -90,7 +93,9 @@ app.get('/events', (c) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
scanListeners.add(listener);
|
scanListeners.add(listener);
|
||||||
s.onAbort(() => { scanListeners.delete(listener); });
|
s.onAbort(() => {
|
||||||
|
scanListeners.delete(listener);
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (!s.closed) {
|
while (!s.closed) {
|
||||||
@@ -102,7 +107,7 @@ app.get('/events', (c) => {
|
|||||||
setTimeout(res, 25_000);
|
setTimeout(res, 25_000);
|
||||||
});
|
});
|
||||||
resolve = null;
|
resolve = null;
|
||||||
if (queue.length === 0) await s.write(': keepalive\n\n');
|
if (queue.length === 0) await s.write(": keepalive\n\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -114,25 +119,31 @@ app.get('/events', (c) => {
|
|||||||
// ─── Core scan logic ──────────────────────────────────────────────────────────
|
// ─── Core scan logic ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function runScan(limit: number | null = null): Promise<void> {
|
async function runScan(limit: number | null = null): Promise<void> {
|
||||||
log(`Scan started${limit ? ` (limit: ${limit})` : ''}`);
|
log(`Scan started${limit ? ` (limit: ${limit})` : ""}`);
|
||||||
scanAbort = new AbortController();
|
scanAbort = new AbortController();
|
||||||
const { signal } = scanAbort;
|
const { signal } = scanAbort;
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
db.prepare('DELETE FROM stream_decisions').run();
|
// Order matters only if foreign keys are enforced without CASCADE; we
|
||||||
db.prepare('DELETE FROM review_plans').run();
|
// have ON DELETE CASCADE on media_streams/review_plans/stream_decisions/
|
||||||
db.prepare('DELETE FROM media_streams').run();
|
// subtitle_files/jobs, so deleting media_items would be enough. List
|
||||||
db.prepare('DELETE FROM media_items').run();
|
// 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 cfg = getAllConfig();
|
||||||
const jellyfinCfg = { url: cfg.jellyfin_url, apiKey: cfg.jellyfin_api_key, userId: cfg.jellyfin_user_id };
|
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 subtitleLanguages: string[] = JSON.parse(cfg.subtitle_languages ?? '["eng","deu","spa"]');
|
||||||
const audioLanguages: string[] = JSON.parse(cfg.audio_languages ?? '[]');
|
const audioLanguages: string[] = JSON.parse(cfg.audio_languages ?? "[]");
|
||||||
const radarrEnabled = cfg.radarr_enabled === '1';
|
const radarrEnabled = cfg.radarr_enabled === "1";
|
||||||
const sonarrEnabled = cfg.sonarr_enabled === '1';
|
const sonarrEnabled = cfg.sonarr_enabled === "1";
|
||||||
|
|
||||||
let processed = 0;
|
let processed = 0;
|
||||||
let errors = 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')
|
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(`
|
const insertStream = db.prepare(`
|
||||||
INSERT INTO media_streams (
|
INSERT INTO media_streams (
|
||||||
item_id, stream_index, type, codec, language, language_display,
|
item_id, stream_index, type, codec, language, language_display,
|
||||||
@@ -181,15 +192,15 @@ async function runScan(limit: number | null = null): Promise<void> {
|
|||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(plan_id, stream_id) DO UPDATE SET action = excluded.action, target_index = excluded.target_index, transcode_codec = excluded.transcode_codec
|
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 getItemByJellyfinId = db.prepare("SELECT id FROM media_items WHERE jellyfin_id = ?");
|
||||||
const getPlanByItemId = db.prepare('SELECT id FROM review_plans WHERE item_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 getStreamsByItemId = db.prepare("SELECT * FROM media_streams WHERE item_id = ?");
|
||||||
|
|
||||||
const itemSource = isDev
|
const itemSource = isDev
|
||||||
? getDevItems(jellyfinCfg)
|
? getDevItems(jellyfinCfg)
|
||||||
: getAllItems(jellyfinCfg, (_fetched, jellyfinTotal) => {
|
: getAllItems(jellyfinCfg, (_fetched, jellyfinTotal) => {
|
||||||
total = limit != null ? Math.min(limit, jellyfinTotal) : jellyfinTotal;
|
total = limit != null ? Math.min(limit, jellyfinTotal) : jellyfinTotal;
|
||||||
});
|
});
|
||||||
for await (const jellyfinItem of itemSource) {
|
for await (const jellyfinItem of itemSource) {
|
||||||
if (signal.aborted) break;
|
if (signal.aborted) break;
|
||||||
if (!isDev && limit != null && processed >= limit) break;
|
if (!isDev && limit != null && processed >= limit) break;
|
||||||
@@ -199,45 +210,67 @@ async function runScan(limit: number | null = null): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
processed++;
|
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 {
|
try {
|
||||||
const providerIds = jellyfinItem.ProviderIds ?? {};
|
const providerIds = jellyfinItem.ProviderIds ?? {};
|
||||||
const imdbId = providerIds['Imdb'] ?? null;
|
const imdbId = providerIds.Imdb ?? null;
|
||||||
const tmdbId = providerIds['Tmdb'] ?? null;
|
const tmdbId = providerIds.Tmdb ?? null;
|
||||||
const tvdbId = providerIds['Tvdb'] ?? null;
|
const tvdbId = providerIds.Tvdb ?? null;
|
||||||
|
|
||||||
let origLang: string | null = extractOriginalLanguage(jellyfinItem);
|
let origLang: string | null = extractOriginalLanguage(jellyfinItem);
|
||||||
let origLangSource = 'jellyfin';
|
let origLangSource = "jellyfin";
|
||||||
let needsReview = origLang ? 0 : 1;
|
let needsReview = origLang ? 0 : 1;
|
||||||
|
|
||||||
if (jellyfinItem.Type === 'Movie' && radarrEnabled && (tmdbId || imdbId)) {
|
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 });
|
const lang = await radarrLang(
|
||||||
if (lang) { if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; origLang = lang; origLangSource = 'radarr'; }
|
{ 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);
|
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
|
// Compute confidence from source agreement
|
||||||
let confidence: 'high' | 'low' = 'low';
|
let confidence: "high" | "low" = "low";
|
||||||
if (!origLang) {
|
if (!origLang) {
|
||||||
confidence = 'low'; // unknown language
|
confidence = "low"; // unknown language
|
||||||
} else if (needsReview) {
|
} else if (needsReview) {
|
||||||
confidence = 'low'; // sources disagree
|
confidence = "low"; // sources disagree
|
||||||
} else {
|
} else {
|
||||||
confidence = 'high'; // language known, no conflicts
|
confidence = "high"; // language known, no conflicts
|
||||||
}
|
}
|
||||||
|
|
||||||
upsertItem.run(
|
upsertItem.run(
|
||||||
jellyfinItem.Id, jellyfinItem.Type === 'Episode' ? 'Episode' : 'Movie',
|
jellyfinItem.Id,
|
||||||
jellyfinItem.Name, jellyfinItem.SeriesName ?? null, jellyfinItem.SeriesId ?? null,
|
jellyfinItem.Type === "Episode" ? "Episode" : "Movie",
|
||||||
jellyfinItem.ParentIndexNumber ?? null, jellyfinItem.IndexNumber ?? null,
|
jellyfinItem.Name,
|
||||||
jellyfinItem.ProductionYear ?? null, jellyfinItem.Path, jellyfinItem.Size ?? null,
|
jellyfinItem.SeriesName ?? null,
|
||||||
jellyfinItem.Container ?? null, origLang, origLangSource, needsReview,
|
jellyfinItem.SeriesId ?? null,
|
||||||
imdbId, tmdbId, tvdbId
|
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 };
|
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 ?? []) {
|
for (const jStream of jellyfinItem.MediaStreams ?? []) {
|
||||||
if (jStream.IsExternal) continue; // skip external subs — not embedded in container
|
if (jStream.IsExternal) continue; // skip external subs — not embedded in container
|
||||||
const s = mapStream(jStream);
|
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 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
|
// Override base confidence with scan-computed value
|
||||||
const finalConfidence = confidence;
|
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 };
|
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) {
|
} catch (err) {
|
||||||
errors++;
|
errors++;
|
||||||
logError(`Error scanning ${jellyfinItem.Name}:`, err);
|
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 */ }
|
try {
|
||||||
emitSse('log', { name: jellyfinItem.Name, type: jellyfinItem.Type, status: 'error', file: jellyfinItem.Path });
|
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`);
|
log(`Scan complete: ${processed} scanned, ${errors} errors`);
|
||||||
emitSse('complete', { scanned: processed, total, errors });
|
emitSse("complete", { scanned: processed, total, errors });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -1,104 +1,106 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from "hono";
|
||||||
import { setConfig, getAllConfig, getDb, getEnvLockedKeys } from '../db/index';
|
import { getAllConfig, getDb, getEnvLockedKeys, setConfig } from "../db/index";
|
||||||
import { testConnection as testJellyfin, getUsers } from '../services/jellyfin';
|
import { getUsers, testConnection as testJellyfin } from "../services/jellyfin";
|
||||||
import { testConnection as testRadarr } from '../services/radarr';
|
import { testConnection as testRadarr } from "../services/radarr";
|
||||||
import { testConnection as testSonarr } from '../services/sonarr';
|
import { testConnection as testSonarr } from "../services/sonarr";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
app.get('/', (c) => {
|
app.get("/", (c) => {
|
||||||
const config = getAllConfig();
|
const config = getAllConfig();
|
||||||
const envLocked = Array.from(getEnvLockedKeys());
|
const envLocked = Array.from(getEnvLockedKeys());
|
||||||
return c.json({ config, envLocked });
|
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 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;
|
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 });
|
const result = await testJellyfin({ url, apiKey });
|
||||||
if (!result.ok) return c.json({ ok: false, error: result.error });
|
if (!result.ok) return c.json({ ok: false, error: result.error });
|
||||||
|
|
||||||
setConfig('jellyfin_url', url);
|
setConfig("jellyfin_url", url);
|
||||||
setConfig('jellyfin_api_key', apiKey);
|
setConfig("jellyfin_api_key", apiKey);
|
||||||
setConfig('setup_complete', '1');
|
setConfig("setup_complete", "1");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const users = await getUsers({ url, apiKey });
|
const users = await getUsers({ url, apiKey });
|
||||||
const admin = users.find((u) => u.Name === 'admin') ?? users[0];
|
const admin = users.find((u) => u.Name === "admin") ?? users[0];
|
||||||
if (admin?.Id) setConfig('jellyfin_user_id', admin.Id);
|
if (admin?.Id) setConfig("jellyfin_user_id", admin.Id);
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({ ok: true });
|
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 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;
|
const apiKey = body.api_key;
|
||||||
|
|
||||||
if (!url || !apiKey) {
|
if (!url || !apiKey) {
|
||||||
setConfig('radarr_enabled', '0');
|
setConfig("radarr_enabled", "0");
|
||||||
return c.json({ ok: false, error: 'URL and API key are required' }, 400);
|
return c.json({ ok: false, error: "URL and API key are required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await testRadarr({ url, apiKey });
|
const result = await testRadarr({ url, apiKey });
|
||||||
if (!result.ok) return c.json({ ok: false, error: result.error });
|
if (!result.ok) return c.json({ ok: false, error: result.error });
|
||||||
|
|
||||||
setConfig('radarr_url', url);
|
setConfig("radarr_url", url);
|
||||||
setConfig('radarr_api_key', apiKey);
|
setConfig("radarr_api_key", apiKey);
|
||||||
setConfig('radarr_enabled', '1');
|
setConfig("radarr_enabled", "1");
|
||||||
|
|
||||||
return c.json({ ok: true });
|
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 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;
|
const apiKey = body.api_key;
|
||||||
|
|
||||||
if (!url || !apiKey) {
|
if (!url || !apiKey) {
|
||||||
setConfig('sonarr_enabled', '0');
|
setConfig("sonarr_enabled", "0");
|
||||||
return c.json({ ok: false, error: 'URL and API key are required' }, 400);
|
return c.json({ ok: false, error: "URL and API key are required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await testSonarr({ url, apiKey });
|
const result = await testSonarr({ url, apiKey });
|
||||||
if (!result.ok) return c.json({ ok: false, error: result.error });
|
if (!result.ok) return c.json({ ok: false, error: result.error });
|
||||||
|
|
||||||
setConfig('sonarr_url', url);
|
setConfig("sonarr_url", url);
|
||||||
setConfig('sonarr_api_key', apiKey);
|
setConfig("sonarr_api_key", apiKey);
|
||||||
setConfig('sonarr_enabled', '1');
|
setConfig("sonarr_enabled", "1");
|
||||||
|
|
||||||
return c.json({ ok: true });
|
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[] }>();
|
const body = await c.req.json<{ langs: string[] }>();
|
||||||
if (body.langs?.length > 0) {
|
if (body.langs?.length > 0) {
|
||||||
setConfig('subtitle_languages', JSON.stringify(body.langs));
|
setConfig("subtitle_languages", JSON.stringify(body.langs));
|
||||||
}
|
}
|
||||||
return c.json({ ok: true });
|
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[] }>();
|
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 });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/clear-scan', (c) => {
|
app.post("/clear-scan", (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
// Delete children first to avoid slow cascade deletes
|
// Delete children first to avoid slow cascade deletes
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
db.prepare('DELETE FROM stream_decisions').run();
|
db.prepare("DELETE FROM stream_decisions").run();
|
||||||
db.prepare('DELETE FROM jobs').run();
|
db.prepare("DELETE FROM jobs").run();
|
||||||
db.prepare('DELETE FROM subtitle_files').run();
|
db.prepare("DELETE FROM subtitle_files").run();
|
||||||
db.prepare('DELETE FROM review_plans').run();
|
db.prepare("DELETE FROM review_plans").run();
|
||||||
db.prepare('DELETE FROM media_streams').run();
|
db.prepare("DELETE FROM media_streams").run();
|
||||||
db.prepare('DELETE FROM media_items').run();
|
db.prepare("DELETE FROM media_items").run();
|
||||||
db.prepare("UPDATE config SET value = '0' WHERE key = 'scan_running'").run();
|
db.prepare("UPDATE config SET value = '0' WHERE key = 'scan_running'").run();
|
||||||
})();
|
})();
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
|
|||||||
@@ -1,44 +1,67 @@
|
|||||||
import { Hono } from 'hono';
|
import { unlinkSync } from "node:fs";
|
||||||
import { getDb, getConfig, getAllConfig } from '../db/index';
|
import { dirname, resolve as resolvePath, sep } from "node:path";
|
||||||
import { buildExtractOnlyCommand } from '../services/ffmpeg';
|
import { Hono } from "hono";
|
||||||
import { normalizeLanguage, getItem, refreshItem, mapStream } from '../services/jellyfin';
|
import { getAllConfig, getConfig, getDb } from "../db/index";
|
||||||
import { parseId } from '../lib/validate';
|
import { error as logError } from "../lib/log";
|
||||||
import type { MediaItem, MediaStream, SubtitleFile, ReviewPlan, StreamDecision } from '../types';
|
import { parseId } from "../lib/validate";
|
||||||
import { unlinkSync } from 'node:fs';
|
import { buildExtractOnlyCommand } from "../services/ffmpeg";
|
||||||
import { dirname, resolve as resolvePath, sep } from 'node:path';
|
import { getItem, mapStream, normalizeLanguage, refreshItem } from "../services/jellyfin";
|
||||||
import { error as logError } from '../lib/log';
|
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision, SubtitleFile } from "../types";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface SubListItem {
|
interface SubListItem {
|
||||||
id: number; jellyfin_id: string; type: string; name: string;
|
id: number;
|
||||||
series_name: string | null; season_number: number | null;
|
jellyfin_id: string;
|
||||||
episode_number: number | null; year: number | null;
|
type: string;
|
||||||
original_language: string | null; file_path: string;
|
name: string;
|
||||||
subs_extracted: number | null; sub_count: number; file_count: number;
|
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 {
|
interface SubSeriesGroup {
|
||||||
series_key: string; series_name: string; original_language: string | null;
|
series_key: string;
|
||||||
season_count: number; episode_count: number;
|
series_name: string;
|
||||||
not_extracted_count: number; extracted_count: number; no_subs_count: number;
|
original_language: string | null;
|
||||||
|
season_count: number;
|
||||||
|
episode_count: number;
|
||||||
|
not_extracted_count: number;
|
||||||
|
extracted_count: number;
|
||||||
|
no_subs_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function loadDetail(db: ReturnType<typeof getDb>, itemId: number) {
|
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;
|
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 subtitleStreams = db
|
||||||
const files = db.prepare('SELECT * FROM subtitle_files WHERE item_id = ? ORDER BY file_path').all(itemId) as SubtitleFile[];
|
.prepare("SELECT * FROM media_streams WHERE item_id = ? AND type = 'Subtitle' ORDER BY stream_index")
|
||||||
const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(itemId) as ReviewPlan | undefined;
|
.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
|
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);
|
const extractCommand = buildExtractOnlyCommand(item, allStreams);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -56,20 +79,25 @@ function loadDetail(db: ReturnType<typeof getDb>, itemId: number) {
|
|||||||
|
|
||||||
function buildSubWhere(filter: string): string {
|
function buildSubWhere(filter: string): string {
|
||||||
switch (filter) {
|
switch (filter) {
|
||||||
case 'not_extracted': return "sub_count > 0 AND COALESCE(rp.subs_extracted, 0) = 0";
|
case "not_extracted":
|
||||||
case 'extracted': return "rp.subs_extracted = 1";
|
return "sub_count > 0 AND COALESCE(rp.subs_extracted, 0) = 0";
|
||||||
case 'no_subs': return "sub_count = 0";
|
case "extracted":
|
||||||
default: return '1=1';
|
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 db = getDb();
|
||||||
const filter = c.req.query('filter') ?? 'all';
|
const filter = c.req.query("filter") ?? "all";
|
||||||
const where = buildSubWhere(filter);
|
const where = buildSubWhere(filter);
|
||||||
|
|
||||||
// Movies
|
// 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,
|
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,
|
mi.episode_number, mi.year, mi.original_language, mi.file_path,
|
||||||
rp.subs_extracted,
|
rp.subs_extracted,
|
||||||
@@ -79,10 +107,12 @@ app.get('/', (c) => {
|
|||||||
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
||||||
WHERE mi.type = 'Movie' AND ${where}
|
WHERE mi.type = 'Movie' AND ${where}
|
||||||
ORDER BY mi.name LIMIT 500
|
ORDER BY mi.name LIMIT 500
|
||||||
`).all() as SubListItem[];
|
`)
|
||||||
|
.all() as SubListItem[];
|
||||||
|
|
||||||
// Series groups
|
// Series groups
|
||||||
const series = db.prepare(`
|
const series = db
|
||||||
|
.prepare(`
|
||||||
SELECT COALESCE(mi.series_jellyfin_id, mi.series_name) as series_key,
|
SELECT COALESCE(mi.series_jellyfin_id, mi.series_name) as series_key,
|
||||||
mi.series_name,
|
mi.series_name,
|
||||||
MAX(mi.original_language) as original_language,
|
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
|
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
||||||
WHERE ${where}
|
WHERE ${where}
|
||||||
GROUP BY series_key ORDER BY mi.series_name
|
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 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 totalExtracted = (
|
||||||
const totalNoSubs = (db.prepare(`
|
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
|
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')
|
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;
|
const totalNotExtracted = totalAll - totalExtracted - totalNoSubs;
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -120,11 +157,12 @@ app.get('/', (c) => {
|
|||||||
|
|
||||||
// ─── Series episodes (subtitles) ─────────────────────────────────────────────
|
// ─── Series episodes (subtitles) ─────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/series/:seriesKey/episodes', (c) => {
|
app.get("/series/:seriesKey/episodes", (c) => {
|
||||||
const db = getDb();
|
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,
|
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,
|
mi.episode_number, mi.year, mi.original_language, mi.file_path,
|
||||||
rp.subs_extracted,
|
rp.subs_extracted,
|
||||||
@@ -135,7 +173,8 @@ app.get('/series/:seriesKey/episodes', (c) => {
|
|||||||
WHERE mi.type = 'Episode'
|
WHERE mi.type = 'Episode'
|
||||||
AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?))
|
AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?))
|
||||||
ORDER BY mi.season_number, mi.episode_number
|
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[]>();
|
const seasonMap = new Map<number | null, SubListItem[]>();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
@@ -159,40 +198,55 @@ app.get('/series/:seriesKey/episodes', (c) => {
|
|||||||
|
|
||||||
// ─── Summary ─────────────────────────────────────────────────────────────────
|
// ─── Summary ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface CategoryRow { language: string | null; is_forced: number; is_hearing_impaired: number; cnt: number }
|
interface CategoryRow {
|
||||||
|
language: string | null;
|
||||||
function variantOf(row: { is_forced: number; is_hearing_impaired: number }): 'forced' | 'cc' | 'standard' {
|
is_forced: number;
|
||||||
if (row.is_forced) return 'forced';
|
is_hearing_impaired: number;
|
||||||
if (row.is_hearing_impaired) return 'cc';
|
cnt: number;
|
||||||
return 'standard';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
const db = getDb();
|
||||||
|
|
||||||
// Embedded count — items with subtitle streams where subs_extracted = 0
|
// 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
|
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'
|
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
|
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
||||||
WHERE COALESCE(rp.subs_extracted, 0) = 0
|
WHERE COALESCE(rp.subs_extracted, 0) = 0
|
||||||
`).get() as { n: number }).n;
|
`)
|
||||||
|
.get() as { n: number }
|
||||||
|
).n;
|
||||||
|
|
||||||
// Stream counts by (language, variant)
|
// Stream counts by (language, variant)
|
||||||
const streamRows = db.prepare(`
|
const streamRows = db
|
||||||
|
.prepare(`
|
||||||
SELECT language, is_forced, is_hearing_impaired, COUNT(*) as cnt
|
SELECT language, is_forced, is_hearing_impaired, COUNT(*) as cnt
|
||||||
FROM media_streams WHERE type = 'Subtitle'
|
FROM media_streams WHERE type = 'Subtitle'
|
||||||
GROUP BY language, is_forced, is_hearing_impaired
|
GROUP BY language, is_forced, is_hearing_impaired
|
||||||
`).all() as CategoryRow[];
|
`)
|
||||||
|
.all() as CategoryRow[];
|
||||||
|
|
||||||
// File counts by (language, variant)
|
// File counts by (language, variant)
|
||||||
const fileRows = db.prepare(`
|
const fileRows = db
|
||||||
|
.prepare(`
|
||||||
SELECT language, is_forced, is_hearing_impaired, COUNT(*) as cnt
|
SELECT language, is_forced, is_hearing_impaired, COUNT(*) as cnt
|
||||||
FROM subtitle_files
|
FROM subtitle_files
|
||||||
GROUP BY language, is_forced, is_hearing_impaired
|
GROUP BY language, is_forced, is_hearing_impaired
|
||||||
`).all() as CategoryRow[];
|
`)
|
||||||
|
.all() as CategoryRow[];
|
||||||
|
|
||||||
// Merge into categories
|
// Merge into categories
|
||||||
const catMap = new Map<string, { language: string | null; variant: string; streamCount: number; fileCount: number }>();
|
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 v = variantOf(r);
|
||||||
const k = catKey(r.language, v);
|
const k = catKey(r.language, v);
|
||||||
const existing = catMap.get(k);
|
const existing = catMap.get(k);
|
||||||
if (existing) { existing.fileCount = r.cnt; }
|
if (existing) {
|
||||||
else { catMap.set(k, { language: r.language, variant: v, streamCount: 0, fileCount: r.cnt }); }
|
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 categories = Array.from(catMap.values()).sort((a, b) => {
|
||||||
const la = a.language ?? 'zzz';
|
const la = a.language ?? "zzz";
|
||||||
const lb = b.language ?? 'zzz';
|
const lb = b.language ?? "zzz";
|
||||||
if (la !== lb) return la.localeCompare(lb);
|
if (la !== lb) return la.localeCompare(lb);
|
||||||
return a.variant.localeCompare(b.variant);
|
return a.variant.localeCompare(b.variant);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Title grouping
|
// Title grouping
|
||||||
const titleRows = db.prepare(`
|
const titleRows = db
|
||||||
|
.prepare(`
|
||||||
SELECT language, title, COUNT(*) as cnt
|
SELECT language, title, COUNT(*) as cnt
|
||||||
FROM media_streams WHERE type = 'Subtitle'
|
FROM media_streams WHERE type = 'Subtitle'
|
||||||
GROUP BY language, title
|
GROUP BY language, title
|
||||||
ORDER BY language, cnt DESC
|
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)
|
// Determine canonical title per language (most common)
|
||||||
const canonicalByLang = new Map<string | null, string | null>();
|
const canonicalByLang = new Map<string | null, string | null>();
|
||||||
@@ -237,19 +296,23 @@ app.get('/summary', (c) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Keep languages from config
|
// Keep languages from config
|
||||||
const raw = getConfig('subtitle_languages');
|
const raw = getConfig("subtitle_languages");
|
||||||
let keepLanguages: string[] = [];
|
let keepLanguages: string[] = [];
|
||||||
try { keepLanguages = JSON.parse(raw ?? '[]'); } catch { /* empty */ }
|
try {
|
||||||
|
keepLanguages = JSON.parse(raw ?? "[]");
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({ embeddedCount, categories, titles, keepLanguages });
|
return c.json({ embeddedCount, categories, titles, keepLanguages });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Detail ──────────────────────────────────────────────────────────────────
|
// ─── Detail ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/:id', (c) => {
|
app.get("/:id", (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = parseId(c.req.param('id'));
|
const id = parseId(c.req.param("id"));
|
||||||
if (id == null) return c.json({ error: 'invalid id' }, 400);
|
if (id == null) return c.json({ error: "invalid id" }, 400);
|
||||||
const detail = loadDetail(db, id);
|
const detail = loadDetail(db, id);
|
||||||
if (!detail) return c.notFound();
|
if (!detail) return c.notFound();
|
||||||
return c.json(detail);
|
return c.json(detail);
|
||||||
@@ -257,19 +320,21 @@ app.get('/:id', (c) => {
|
|||||||
|
|
||||||
// ─── Edit stream language ────────────────────────────────────────────────────
|
// ─── Edit stream language ────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.patch('/:id/stream/:streamId/language', async (c) => {
|
app.patch("/:id/stream/:streamId/language", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const itemId = parseId(c.req.param('id'));
|
const itemId = parseId(c.req.param("id"));
|
||||||
const streamId = parseId(c.req.param('streamId'));
|
const streamId = parseId(c.req.param("streamId"));
|
||||||
if (itemId == null || streamId == null) return c.json({ error: 'invalid id' }, 400);
|
if (itemId == null || streamId == null) return c.json({ error: "invalid id" }, 400);
|
||||||
const body = await c.req.json<{ language: string }>();
|
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();
|
if (!stream) return c.notFound();
|
||||||
|
|
||||||
const normalized = lang ? normalizeLanguage(lang) : null;
|
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);
|
const detail = loadDetail(db, itemId);
|
||||||
if (!detail) return c.notFound();
|
if (!detail) return c.notFound();
|
||||||
@@ -278,17 +343,19 @@ app.patch('/:id/stream/:streamId/language', async (c) => {
|
|||||||
|
|
||||||
// ─── Edit stream title ──────────────────────────────────────────────────────
|
// ─── Edit stream title ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.patch('/:id/stream/:streamId/title', async (c) => {
|
app.patch("/:id/stream/:streamId/title", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const itemId = parseId(c.req.param('id'));
|
const itemId = parseId(c.req.param("id"));
|
||||||
const streamId = parseId(c.req.param('streamId'));
|
const streamId = parseId(c.req.param("streamId"));
|
||||||
if (itemId == null || streamId == null) return c.json({ error: 'invalid id' }, 400);
|
if (itemId == null || streamId == null) return c.json({ error: "invalid id" }, 400);
|
||||||
const body = await c.req.json<{ title: string }>();
|
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();
|
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);
|
const detail = loadDetail(db, itemId);
|
||||||
if (!detail) return c.notFound();
|
if (!detail) return c.notFound();
|
||||||
@@ -297,22 +364,28 @@ app.patch('/:id/stream/:streamId/title', async (c) => {
|
|||||||
|
|
||||||
// ─── Extract all ──────────────────────────────────────────────────────────────
|
// ─── Extract all ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/extract-all', (c) => {
|
app.post("/extract-all", (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
// Find items with subtitle streams that haven't been extracted yet
|
// 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
|
SELECT mi.* FROM media_items mi
|
||||||
WHERE EXISTS (SELECT 1 FROM media_streams ms WHERE ms.item_id = mi.id AND ms.type = 'Subtitle')
|
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 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'))
|
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;
|
let queued = 0;
|
||||||
for (const item of items) {
|
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);
|
const command = buildExtractOnlyCommand(item, streams);
|
||||||
if (!command) continue;
|
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++;
|
queued++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,22 +394,26 @@ app.post('/extract-all', (c) => {
|
|||||||
|
|
||||||
// ─── Extract ─────────────────────────────────────────────────────────────────
|
// ─── Extract ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/:id/extract', (c) => {
|
app.post("/:id/extract", (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = parseId(c.req.param('id'));
|
const id = parseId(c.req.param("id"));
|
||||||
if (id == null) return c.json({ error: 'invalid id' }, 400);
|
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();
|
if (!item) return c.notFound();
|
||||||
|
|
||||||
const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(id) as ReviewPlan | undefined;
|
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);
|
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);
|
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 });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -352,36 +429,46 @@ function isSidecarOfItem(filePath: string, videoPath: string): boolean {
|
|||||||
return targetDir === videoDir || targetDir.startsWith(videoDir + sep);
|
return targetDir === videoDir || targetDir.startsWith(videoDir + sep);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.delete('/:id/files/:fileId', (c) => {
|
app.delete("/:id/files/:fileId", (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const itemId = parseId(c.req.param('id'));
|
const itemId = parseId(c.req.param("id"));
|
||||||
const fileId = parseId(c.req.param('fileId'));
|
const fileId = parseId(c.req.param("fileId"));
|
||||||
if (itemId == null || fileId == null) return c.json({ error: 'invalid id' }, 400);
|
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();
|
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)) {
|
if (!item || !isSidecarOfItem(file.file_path, item.file_path)) {
|
||||||
logError(`Refusing to delete subtitle file outside media dir: ${file.file_path}`);
|
logError(`Refusing to delete subtitle file outside media dir: ${file.file_path}`);
|
||||||
db.prepare('DELETE FROM subtitle_files WHERE id = ?').run(fileId);
|
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);
|
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 */ }
|
try {
|
||||||
db.prepare('DELETE FROM subtitle_files WHERE id = ?').run(fileId);
|
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 });
|
return c.json({ ok: true, files });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Rescan ──────────────────────────────────────────────────────────────────
|
// ─── Rescan ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/:id/rescan', async (c) => {
|
app.post("/:id/rescan", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = parseId(c.req.param('id'));
|
const id = parseId(c.req.param("id"));
|
||||||
if (id == null) return c.json({ error: 'invalid id' }, 400);
|
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();
|
if (!item) return c.notFound();
|
||||||
|
|
||||||
const cfg = getAllConfig();
|
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)
|
title, is_default, is_forced, is_hearing_impaired, channels, channel_layout, bit_rate, sample_rate)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
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 ?? []) {
|
for (const jStream of fresh.MediaStreams ?? []) {
|
||||||
if (jStream.IsExternal) continue;
|
if (jStream.IsExternal) continue;
|
||||||
const s = mapStream(jStream);
|
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 ─────────────────────────────────────────────
|
// ─── Batch delete subtitle files ─────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/batch-delete', async (c) => {
|
app.post("/batch-delete", async (c) => {
|
||||||
const db = getDb();
|
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;
|
let deleted = 0;
|
||||||
for (const cat of body.categories) {
|
for (const cat of body.categories) {
|
||||||
const isForced = cat.variant === 'forced' ? 1 : 0;
|
const isForced = cat.variant === "forced" ? 1 : 0;
|
||||||
const isHI = cat.variant === 'cc' ? 1 : 0;
|
const isHI = cat.variant === "cc" ? 1 : 0;
|
||||||
|
|
||||||
let files: SubtitleFile[];
|
let files: SubtitleFile[];
|
||||||
if (cat.language === null) {
|
if (cat.language === null) {
|
||||||
files = db.prepare(`
|
files = db
|
||||||
|
.prepare(`
|
||||||
SELECT * FROM subtitle_files
|
SELECT * FROM subtitle_files
|
||||||
WHERE language IS NULL AND is_forced = ? AND is_hearing_impaired = ?
|
WHERE language IS NULL AND is_forced = ? AND is_hearing_impaired = ?
|
||||||
`).all(isForced, isHI) as SubtitleFile[];
|
`)
|
||||||
|
.all(isForced, isHI) as SubtitleFile[];
|
||||||
} else {
|
} else {
|
||||||
files = db.prepare(`
|
files = db
|
||||||
|
.prepare(`
|
||||||
SELECT * FROM subtitle_files
|
SELECT * FROM subtitle_files
|
||||||
WHERE language = ? AND is_forced = ? AND is_hearing_impaired = ?
|
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) {
|
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)) {
|
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 {
|
} else {
|
||||||
logError(`Refusing to delete subtitle file outside media dir: ${file.file_path}`);
|
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++;
|
deleted++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset subs_extracted for affected items that now have no subtitle files
|
// Reset subs_extracted for affected items that now have no subtitle files
|
||||||
const affectedItems = new Set(files.map((f) => f.item_id));
|
const affectedItems = new Set(files.map((f) => f.item_id));
|
||||||
for (const itemId of affectedItems) {
|
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) {
|
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 ────────────────────────────────────────────────────────
|
// ─── Normalize titles ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/normalize-titles', (c) => {
|
app.post("/normalize-titles", (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
// Get title groups per language
|
// Get title groups per language
|
||||||
const titleRows = db.prepare(`
|
const titleRows = db
|
||||||
|
.prepare(`
|
||||||
SELECT language, title, COUNT(*) as cnt
|
SELECT language, title, COUNT(*) as cnt
|
||||||
FROM media_streams WHERE type = 'Subtitle'
|
FROM media_streams WHERE type = 'Subtitle'
|
||||||
GROUP BY language, title
|
GROUP BY language, title
|
||||||
ORDER BY language, cnt DESC
|
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
|
// Find canonical (most common) title per language
|
||||||
const canonicalByLang = new Map<string | null, string | null>();
|
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
|
// Find all streams matching this language+title and set custom_title on their decisions
|
||||||
let streams: { id: number; item_id: number }[];
|
let streams: { id: number; item_id: number }[];
|
||||||
if (r.language === null) {
|
if (r.language === null) {
|
||||||
streams = db.prepare(`
|
streams = db
|
||||||
|
.prepare(`
|
||||||
SELECT id, item_id FROM media_streams
|
SELECT id, item_id FROM media_streams
|
||||||
WHERE type = 'Subtitle' AND language IS NULL AND title IS ?
|
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 {
|
} else {
|
||||||
streams = db.prepare(`
|
streams = db
|
||||||
|
.prepare(`
|
||||||
SELECT id, item_id FROM media_streams
|
SELECT id, item_id FROM media_streams
|
||||||
WHERE type = 'Subtitle' AND language = ? AND title IS ?
|
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) {
|
for (const stream of streams) {
|
||||||
// Ensure review_plan exists
|
// 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) {
|
if (!plan) {
|
||||||
db.prepare("INSERT INTO review_plans (item_id, status, is_noop) VALUES (?, 'pending', 0)").run(stream.item_id);
|
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
|
// 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) {
|
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 {
|
} 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++;
|
normalized++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,28 @@
|
|||||||
import { Database } from 'bun:sqlite';
|
import { Database } from "bun:sqlite";
|
||||||
import { join } from 'node:path';
|
import { mkdirSync } from "node:fs";
|
||||||
import { mkdirSync } from 'node:fs';
|
import { join } from "node:path";
|
||||||
import { SCHEMA, DEFAULT_CONFIG } from './schema';
|
import { DEFAULT_CONFIG, SCHEMA } from "./schema";
|
||||||
|
|
||||||
const dataDir = process.env.DATA_DIR ?? './data';
|
const dataDir = process.env.DATA_DIR ?? "./data";
|
||||||
mkdirSync(dataDir, { recursive: true });
|
mkdirSync(dataDir, { recursive: true });
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
const dbPath = join(dataDir, isDev ? 'netfelix-dev.db' : 'netfelix.db');
|
const dbPath = join(dataDir, isDev ? "netfelix-dev.db" : "netfelix.db");
|
||||||
|
|
||||||
// ─── Env-var → config key mapping ─────────────────────────────────────────────
|
// ─── Env-var → config key mapping ─────────────────────────────────────────────
|
||||||
|
|
||||||
const ENV_MAP: Record<string, string> = {
|
const ENV_MAP: Record<string, string> = {
|
||||||
jellyfin_url: 'JELLYFIN_URL',
|
jellyfin_url: "JELLYFIN_URL",
|
||||||
jellyfin_api_key: 'JELLYFIN_API_KEY',
|
jellyfin_api_key: "JELLYFIN_API_KEY",
|
||||||
jellyfin_user_id: 'JELLYFIN_USER_ID',
|
jellyfin_user_id: "JELLYFIN_USER_ID",
|
||||||
radarr_url: 'RADARR_URL',
|
radarr_url: "RADARR_URL",
|
||||||
radarr_api_key: 'RADARR_API_KEY',
|
radarr_api_key: "RADARR_API_KEY",
|
||||||
radarr_enabled: 'RADARR_ENABLED',
|
radarr_enabled: "RADARR_ENABLED",
|
||||||
sonarr_url: 'SONARR_URL',
|
sonarr_url: "SONARR_URL",
|
||||||
sonarr_api_key: 'SONARR_API_KEY',
|
sonarr_api_key: "SONARR_API_KEY",
|
||||||
sonarr_enabled: 'SONARR_ENABLED',
|
sonarr_enabled: "SONARR_ENABLED",
|
||||||
subtitle_languages: 'SUBTITLE_LANGUAGES',
|
subtitle_languages: "SUBTITLE_LANGUAGES",
|
||||||
audio_languages: 'AUDIO_LANGUAGES',
|
audio_languages: "AUDIO_LANGUAGES",
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Read a config key from environment variables (returns null if not set). */
|
/** 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;
|
if (!envKey) return null;
|
||||||
const val = process.env[envKey];
|
const val = process.env[envKey];
|
||||||
if (!val) return null;
|
if (!val) return null;
|
||||||
if (key.endsWith('_enabled')) return val === '1' || val.toLowerCase() === 'true' ? '1' : '0';
|
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 === "subtitle_languages" || key === "audio_languages")
|
||||||
if (key.endsWith('_url')) return val.replace(/\/$/, '');
|
return JSON.stringify(val.split(",").map((s) => s.trim()));
|
||||||
|
if (key.endsWith("_url")) return val.replace(/\/$/, "");
|
||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,23 +52,49 @@ export function getDb(): Database {
|
|||||||
_db = new Database(dbPath, { create: true });
|
_db = new Database(dbPath, { create: true });
|
||||||
_db.exec(SCHEMA);
|
_db.exec(SCHEMA);
|
||||||
// Migrations for columns added after initial release
|
// Migrations for columns added after initial release
|
||||||
try { _db.exec('ALTER TABLE stream_decisions ADD COLUMN custom_title TEXT'); } catch { /* already exists */ }
|
try {
|
||||||
try { _db.exec('ALTER TABLE review_plans ADD COLUMN subs_extracted INTEGER NOT NULL DEFAULT 0'); } catch { /* already exists */ }
|
_db.exec("ALTER TABLE stream_decisions ADD COLUMN custom_title TEXT");
|
||||||
try { _db.exec("ALTER TABLE jobs ADD COLUMN job_type TEXT NOT NULL DEFAULT 'audio'"); } catch { /* already exists */ }
|
} 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
|
// Apple compat pipeline columns
|
||||||
try { _db.exec("ALTER TABLE review_plans ADD COLUMN confidence TEXT NOT NULL DEFAULT 'low'"); } catch { /* already exists */ }
|
try {
|
||||||
try { _db.exec('ALTER TABLE review_plans ADD COLUMN apple_compat TEXT'); } catch { /* already exists */ }
|
_db.exec("ALTER TABLE review_plans ADD COLUMN confidence TEXT NOT NULL DEFAULT 'low'");
|
||||||
try { _db.exec("ALTER TABLE review_plans ADD COLUMN job_type TEXT NOT NULL DEFAULT 'copy'"); } catch { /* already exists */ }
|
} catch {
|
||||||
try { _db.exec('ALTER TABLE stream_decisions ADD COLUMN transcode_codec TEXT'); } catch { /* already exists */ }
|
/* 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);
|
seedDefaults(_db);
|
||||||
|
|
||||||
return _db;
|
return _db;
|
||||||
}
|
}
|
||||||
|
|
||||||
function seedDefaults(db: Database): void {
|
function seedDefaults(db: Database): void {
|
||||||
const insert = db.prepare(
|
const insert = db.prepare("INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)");
|
||||||
'INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)'
|
|
||||||
);
|
|
||||||
for (const [key, value] of Object.entries(DEFAULT_CONFIG)) {
|
for (const [key, value] of Object.entries(DEFAULT_CONFIG)) {
|
||||||
insert.run(key, value);
|
insert.run(key, value);
|
||||||
}
|
}
|
||||||
@@ -79,17 +105,13 @@ export function getConfig(key: string): string | null {
|
|||||||
const fromEnv = envValue(key);
|
const fromEnv = envValue(key);
|
||||||
if (fromEnv !== null) return fromEnv;
|
if (fromEnv !== null) return fromEnv;
|
||||||
// Auto-complete setup when all required Jellyfin env vars are present
|
// Auto-complete setup when all required Jellyfin env vars are present
|
||||||
if (key === 'setup_complete' && isEnvConfigured()) return '1';
|
if (key === "setup_complete" && isEnvConfigured()) return "1";
|
||||||
const row = getDb()
|
const row = getDb().prepare("SELECT value FROM config WHERE key = ?").get(key) as { value: string } | undefined;
|
||||||
.prepare('SELECT value FROM config WHERE key = ?')
|
|
||||||
.get(key) as { value: string } | undefined;
|
|
||||||
return row?.value ?? null;
|
return row?.value ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setConfig(key: string, value: string): void {
|
export function setConfig(key: string, value: string): void {
|
||||||
getDb()
|
getDb().prepare("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)").run(key, value);
|
||||||
.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)')
|
|
||||||
.run(key, value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the set of config keys currently overridden by environment variables. */
|
/** 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> {
|
export function getAllConfig(): Record<string, string> {
|
||||||
const rows = getDb()
|
const rows = getDb().prepare("SELECT key, value FROM config").all() as { key: string; value: string }[];
|
||||||
.prepare('SELECT key, value FROM config')
|
const result = Object.fromEntries(rows.map((r) => [r.key, r.value ?? ""]));
|
||||||
.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
|
// Apply env overrides on top of DB values
|
||||||
for (const key of Object.keys(ENV_MAP)) {
|
for (const key of Object.keys(ENV_MAP)) {
|
||||||
const fromEnv = envValue(key);
|
const fromEnv = envValue(key);
|
||||||
if (fromEnv !== null) result[key] = fromEnv;
|
if (fromEnv !== null) result[key] = fromEnv;
|
||||||
}
|
}
|
||||||
// Auto-complete setup when all required Jellyfin env vars are present
|
// 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;
|
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> = {
|
export const DEFAULT_CONFIG: Record<string, string> = {
|
||||||
setup_complete: '0',
|
setup_complete: "0",
|
||||||
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: '0',
|
radarr_enabled: "0",
|
||||||
sonarr_url: '',
|
sonarr_url: "",
|
||||||
sonarr_api_key: '',
|
sonarr_api_key: "",
|
||||||
sonarr_enabled: '0',
|
sonarr_enabled: "0",
|
||||||
subtitle_languages: JSON.stringify(['eng', 'deu', 'spa']),
|
subtitle_languages: JSON.stringify(["eng", "deu", "spa"]),
|
||||||
audio_languages: '[]',
|
audio_languages: "[]",
|
||||||
|
|
||||||
scan_running: '0',
|
scan_running: "0",
|
||||||
job_sleep_seconds: '0',
|
job_sleep_seconds: "0",
|
||||||
schedule_enabled: '0',
|
schedule_enabled: "0",
|
||||||
schedule_start: '01:00',
|
schedule_start: "01:00",
|
||||||
schedule_end: '07:00',
|
schedule_end: "07:00",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,70 +1,69 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from "hono";
|
||||||
import { serveStatic } from 'hono/bun';
|
import { serveStatic } from "hono/bun";
|
||||||
import { cors } from 'hono/cors';
|
import { cors } from "hono/cors";
|
||||||
import { getDb, getConfig } from './db/index';
|
import dashboardRoutes from "./api/dashboard";
|
||||||
import { log } from './lib/log';
|
import executeRoutes from "./api/execute";
|
||||||
|
import pathsRoutes from "./api/paths";
|
||||||
import setupRoutes from './api/setup';
|
import reviewRoutes from "./api/review";
|
||||||
import scanRoutes from './api/scan';
|
import scanRoutes from "./api/scan";
|
||||||
import reviewRoutes from './api/review';
|
import setupRoutes from "./api/setup";
|
||||||
import executeRoutes from './api/execute';
|
import subtitlesRoutes from "./api/subtitles";
|
||||||
import subtitlesRoutes from './api/subtitles';
|
import { getDb } from "./db/index";
|
||||||
import dashboardRoutes from './api/dashboard';
|
import { log } from "./lib/log";
|
||||||
import pathsRoutes from './api/paths';
|
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
// ─── CORS (dev: Vite on :5173 talks to Hono on :3000) ────────────────────────
|
// ─── 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 ──────────────────────────────────────────────────────────
|
// ─── Request logging ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.use('/api/*', async (c, next) => {
|
app.use("/api/*", async (c, next) => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
await next();
|
await next();
|
||||||
const ms = Date.now() - start;
|
const ms = Date.now() - start;
|
||||||
// Skip noisy SSE/polling endpoints
|
// 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)`);
|
log(`${c.req.method} ${c.req.path} → ${c.res.status} (${ms}ms)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── API routes ───────────────────────────────────────────────────────────────
|
// ─── API routes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import pkg from '../package.json';
|
import pkg from "../package.json";
|
||||||
|
|
||||||
app.get('/api/version', (c) => c.json({ version: pkg.version }));
|
app.get("/api/version", (c) => c.json({ version: pkg.version }));
|
||||||
app.route('/api/dashboard', dashboardRoutes);
|
app.route("/api/dashboard", dashboardRoutes);
|
||||||
app.route('/api/setup', setupRoutes);
|
app.route("/api/setup", setupRoutes);
|
||||||
app.route('/api/scan', scanRoutes);
|
app.route("/api/scan", scanRoutes);
|
||||||
app.route('/api/review', reviewRoutes);
|
app.route("/api/review", reviewRoutes);
|
||||||
app.route('/api/execute', executeRoutes);
|
app.route("/api/execute", executeRoutes);
|
||||||
app.route('/api/subtitles', subtitlesRoutes);
|
app.route("/api/subtitles", subtitlesRoutes);
|
||||||
app.route('/api/paths', pathsRoutes);
|
app.route("/api/paths", pathsRoutes);
|
||||||
|
|
||||||
// ─── Static assets (production: serve Vite build) ────────────────────────────
|
// ─── Static assets (production: serve Vite build) ────────────────────────────
|
||||||
|
|
||||||
app.use('/assets/*', serveStatic({ root: './dist' }));
|
app.use("/assets/*", serveStatic({ root: "./dist" }));
|
||||||
app.use('/favicon.ico', serveStatic({ path: './dist/favicon.ico' }));
|
app.use("/favicon.ico", serveStatic({ path: "./dist/favicon.ico" }));
|
||||||
|
|
||||||
// ─── SPA fallback ─────────────────────────────────────────────────────────────
|
// ─── SPA fallback ─────────────────────────────────────────────────────────────
|
||||||
// All non-API routes serve the React index.html so TanStack Router handles them.
|
// All non-API routes serve the React index.html so TanStack Router handles them.
|
||||||
|
|
||||||
app.get('*', (c) => {
|
app.get("*", (c) => {
|
||||||
const accept = c.req.header('Accept') ?? '';
|
const _accept = c.req.header("Accept") ?? "";
|
||||||
if (c.req.path.startsWith('/api/')) return c.notFound();
|
if (c.req.path.startsWith("/api/")) return c.notFound();
|
||||||
// In dev the Vite server handles the SPA. In production serve dist/index.html.
|
// In dev the Vite server handles the SPA. In production serve dist/index.html.
|
||||||
try {
|
try {
|
||||||
const html = Bun.file('./dist/index.html').text();
|
const html = Bun.file("./dist/index.html").text();
|
||||||
return html.then((text) => c.html(text));
|
return html.then((text) => c.html(text));
|
||||||
} catch {
|
} 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 ────────────────────────────────────────────────────────────────────
|
// ─── 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}`);
|
log(`netfelix-audio-fix v${pkg.version} starting on http://localhost:${port}`);
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,34 @@
|
|||||||
import { describe, test, expect } from 'bun:test';
|
import { describe, expect, test } from "bun:test";
|
||||||
import { parseId, isOneOf } from '../validate';
|
import { isOneOf, parseId } from "../validate";
|
||||||
|
|
||||||
describe('parseId', () => {
|
describe("parseId", () => {
|
||||||
test('returns the integer for valid numeric strings', () => {
|
test("returns the integer for valid numeric strings", () => {
|
||||||
expect(parseId('42')).toBe(42);
|
expect(parseId("42")).toBe(42);
|
||||||
expect(parseId('1')).toBe(1);
|
expect(parseId("1")).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns null for invalid, negative, zero, or missing ids', () => {
|
test("returns null for invalid, negative, zero, or missing ids", () => {
|
||||||
expect(parseId('0')).toBe(null);
|
expect(parseId("0")).toBe(null);
|
||||||
expect(parseId('-1')).toBe(null);
|
expect(parseId("-1")).toBe(null);
|
||||||
expect(parseId('abc')).toBe(null);
|
expect(parseId("abc")).toBe(null);
|
||||||
expect(parseId('')).toBe(null);
|
expect(parseId("")).toBe(null);
|
||||||
expect(parseId(undefined)).toBe(null);
|
expect(parseId(undefined)).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parses leading integer from mixed strings (parseInt semantics)', () => {
|
test("parses leading integer from mixed strings (parseInt semantics)", () => {
|
||||||
expect(parseId('42abc')).toBe(42);
|
expect(parseId("42abc")).toBe(42);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isOneOf', () => {
|
describe("isOneOf", () => {
|
||||||
test('narrows to allowed string literals', () => {
|
test("narrows to allowed string literals", () => {
|
||||||
expect(isOneOf('keep', ['keep', 'remove'] as const)).toBe(true);
|
expect(isOneOf("keep", ["keep", "remove"] as const)).toBe(true);
|
||||||
expect(isOneOf('remove', ['keep', 'remove'] as const)).toBe(true);
|
expect(isOneOf("remove", ["keep", "remove"] as const)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects disallowed values and non-strings', () => {
|
test("rejects disallowed values and non-strings", () => {
|
||||||
expect(isOneOf('delete', ['keep', 'remove'] as const)).toBe(false);
|
expect(isOneOf("delete", ["keep", "remove"] as const)).toBe(false);
|
||||||
expect(isOneOf(null, ['keep', 'remove'] as const)).toBe(false);
|
expect(isOneOf(null, ["keep", "remove"] as const)).toBe(false);
|
||||||
expect(isOneOf(42, ['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. */
|
/** Parse a route param as a positive integer id. Returns null if invalid. */
|
||||||
export function parseId(raw: string | undefined): number | null {
|
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. */
|
/** True if value is one of the allowed strings. */
|
||||||
export function isOneOf<T extends string>(value: unknown, allowed: readonly T[]): value is T {
|
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 { describe, expect, test } from "bun:test";
|
||||||
import { analyzeItem } from '../analyzer';
|
import type { MediaStream } from "../../types";
|
||||||
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 {
|
function stream(o: StreamOverride): MediaStream {
|
||||||
return {
|
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', () => {
|
describe("analyzeItem — audio keep rules", () => {
|
||||||
test('keeps only OG + configured languages, drops others', () => {
|
test("keeps only OG + configured languages, drops others", () => {
|
||||||
const streams = [
|
const streams = [
|
||||||
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }),
|
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
||||||
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }),
|
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: 3, type: "Audio", stream_index: 2, codec: "aac", language: "deu" }),
|
||||||
stream({ id: 4, type: 'Audio', stream_index: 3, codec: 'aac', language: 'fra' }),
|
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: [],
|
subtitleLanguages: [],
|
||||||
audioLanguages: ['deu'],
|
audioLanguages: ["deu"],
|
||||||
});
|
});
|
||||||
const actions = Object.fromEntries(result.decisions.map(d => [d.stream_id, d.action]));
|
const actions = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.action]));
|
||||||
expect(actions).toEqual({ 1: 'keep', 2: 'keep', 3: 'keep', 4: 'remove' });
|
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 = [
|
const streams = [
|
||||||
stream({ id: 1, type: 'Audio', stream_index: 0, language: 'eng' }),
|
stream({ id: 1, type: "Audio", stream_index: 0, language: "eng" }),
|
||||||
stream({ id: 2, type: 'Audio', stream_index: 1, language: 'deu' }),
|
stream({ id: 2, type: "Audio", stream_index: 1, language: "deu" }),
|
||||||
stream({ id: 3, type: 'Audio', stream_index: 2, language: 'fra' }),
|
stream({ id: 3, type: "Audio", stream_index: 2, language: "fra" }),
|
||||||
];
|
];
|
||||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: null, needs_review: 1 }, streams, {
|
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: null, needs_review: 1 }, streams, {
|
||||||
subtitleLanguages: [],
|
subtitleLanguages: [],
|
||||||
audioLanguages: ['deu'],
|
audioLanguages: ["deu"],
|
||||||
});
|
});
|
||||||
expect(result.decisions.every(d => d.action === 'keep')).toBe(true);
|
expect(result.decisions.every((d) => d.action === "keep")).toBe(true);
|
||||||
expect(result.notes.some(n => n.includes('manual review'))).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 = [
|
const streams = [
|
||||||
stream({ id: 1, type: 'Audio', stream_index: 0, language: 'eng' }),
|
stream({ id: 1, type: "Audio", stream_index: 0, language: "eng" }),
|
||||||
stream({ id: 2, type: 'Audio', stream_index: 1, language: null }),
|
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: [],
|
subtitleLanguages: [],
|
||||||
audioLanguages: [],
|
audioLanguages: [],
|
||||||
});
|
});
|
||||||
const byId = Object.fromEntries(result.decisions.map(d => [d.stream_id, d.action]));
|
const byId = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.action]));
|
||||||
expect(byId[2]).toBe('keep');
|
expect(byId[2]).toBe("keep");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('normalizes language codes (ger → deu)', () => {
|
test("normalizes language codes (ger → deu)", () => {
|
||||||
const streams = [
|
const streams = [stream({ id: 1, type: "Audio", stream_index: 0, language: "ger" })];
|
||||||
stream({ id: 1, type: 'Audio', stream_index: 0, language: 'ger' }),
|
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "deu" }, streams, {
|
||||||
];
|
|
||||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'deu' }, streams, {
|
|
||||||
subtitleLanguages: [],
|
subtitleLanguages: [],
|
||||||
audioLanguages: [],
|
audioLanguages: [],
|
||||||
});
|
});
|
||||||
expect(result.decisions[0].action).toBe('keep');
|
expect(result.decisions[0].action).toBe("keep");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('analyzeItem — audio ordering', () => {
|
describe("analyzeItem — audio ordering", () => {
|
||||||
test('OG first, then additional languages in configured order', () => {
|
test("OG first, then additional languages in configured order", () => {
|
||||||
const streams = [
|
const streams = [
|
||||||
stream({ id: 10, type: 'Audio', stream_index: 0, codec: 'aac', language: 'deu' }),
|
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: 11, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }),
|
||||||
stream({ id: 12, type: 'Audio', stream_index: 2, codec: 'aac', language: 'spa' }),
|
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: [],
|
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[11]).toBe(0); // eng (OG) first
|
||||||
expect(byId[10]).toBe(1); // deu second
|
expect(byId[10]).toBe(1); // deu second
|
||||||
expect(byId[12]).toBe(2); // spa third
|
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 = [
|
const streams = [
|
||||||
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }),
|
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
||||||
stream({ id: 2, type: 'Audio', stream_index: 1, language: 'deu' }),
|
stream({ id: 2, type: "Audio", stream_index: 1, language: "deu" }),
|
||||||
stream({ id: 3, type: 'Audio', stream_index: 2, language: 'eng' }),
|
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: [],
|
subtitleLanguages: [],
|
||||||
audioLanguages: ['deu'],
|
audioLanguages: ["deu"],
|
||||||
});
|
});
|
||||||
expect(result.is_noop).toBe(false);
|
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 = [
|
const streams = [
|
||||||
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }),
|
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
||||||
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }),
|
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: 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: [],
|
subtitleLanguages: [],
|
||||||
audioLanguages: ['deu'],
|
audioLanguages: ["deu"],
|
||||||
});
|
});
|
||||||
expect(result.is_noop).toBe(true);
|
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 = [
|
const streams = [
|
||||||
stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'aac', language: 'eng' }),
|
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: 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: [],
|
subtitleLanguages: [],
|
||||||
audioLanguages: [],
|
audioLanguages: [],
|
||||||
});
|
});
|
||||||
@@ -135,27 +133,27 @@ describe('analyzeItem — audio ordering', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('analyzeItem — subtitles & is_noop', () => {
|
describe("analyzeItem — subtitles & is_noop", () => {
|
||||||
test('subtitles are always marked remove (extracted to sidecar)', () => {
|
test("subtitles are always marked remove (extracted to sidecar)", () => {
|
||||||
const streams = [
|
const streams = [
|
||||||
stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'aac', 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' }),
|
stream({ id: 2, type: "Subtitle", stream_index: 1, codec: "subrip", language: "eng" }),
|
||||||
];
|
];
|
||||||
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: 'eng' }, streams, {
|
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, {
|
||||||
subtitleLanguages: ['eng'],
|
subtitleLanguages: ["eng"],
|
||||||
audioLanguages: [],
|
audioLanguages: [],
|
||||||
});
|
});
|
||||||
const subDec = result.decisions.find(d => d.stream_id === 2);
|
const subDec = result.decisions.find((d) => d.stream_id === 2);
|
||||||
expect(subDec?.action).toBe('remove');
|
expect(subDec?.action).toBe("remove");
|
||||||
expect(result.is_noop).toBe(false); // subs present → not noop
|
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 = [
|
const streams = [
|
||||||
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }),
|
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
||||||
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }),
|
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: [],
|
subtitleLanguages: [],
|
||||||
audioLanguages: [],
|
audioLanguages: [],
|
||||||
});
|
});
|
||||||
@@ -163,29 +161,25 @@ describe('analyzeItem — subtitles & is_noop', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('analyzeItem — transcode targets', () => {
|
describe("analyzeItem — transcode targets", () => {
|
||||||
test('DTS on mp4 → transcode to eac3', () => {
|
test("DTS on mp4 → transcode to eac3", () => {
|
||||||
const streams = [
|
const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "dts", language: "eng" })];
|
||||||
stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'dts', language: 'eng' }),
|
const result = analyzeItem({ original_language: "eng", needs_review: 0, container: "mp4" }, streams, {
|
||||||
];
|
|
||||||
const result = analyzeItem({ original_language: 'eng', needs_review: 0, container: 'mp4' }, streams, {
|
|
||||||
subtitleLanguages: [],
|
subtitleLanguages: [],
|
||||||
audioLanguages: [],
|
audioLanguages: [],
|
||||||
});
|
});
|
||||||
expect(result.decisions[0].transcode_codec).toBe('eac3');
|
expect(result.decisions[0].transcode_codec).toBe("eac3");
|
||||||
expect(result.job_type).toBe('transcode');
|
expect(result.job_type).toBe("transcode");
|
||||||
expect(result.is_noop).toBe(false);
|
expect(result.is_noop).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('AAC passes through without transcode', () => {
|
test("AAC passes through without transcode", () => {
|
||||||
const streams = [
|
const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "eng" })];
|
||||||
stream({ id: 1, type: 'Audio', stream_index: 0, codec: 'aac', language: 'eng' }),
|
const result = analyzeItem({ original_language: "eng", needs_review: 0, container: "mp4" }, streams, {
|
||||||
];
|
|
||||||
const result = analyzeItem({ original_language: 'eng', needs_review: 0, container: 'mp4' }, streams, {
|
|
||||||
subtitleLanguages: [],
|
subtitleLanguages: [],
|
||||||
audioLanguages: [],
|
audioLanguages: [],
|
||||||
});
|
});
|
||||||
expect(result.decisions[0].transcode_codec).toBe(null);
|
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 { describe, expect, test } from "bun:test";
|
||||||
import { buildCommand, buildPipelineCommand, shellQuote, sortKeptStreams, predictExtractedFiles } from '../ffmpeg';
|
import type { MediaItem, MediaStream, StreamDecision } from "../../types";
|
||||||
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 {
|
return {
|
||||||
item_id: 1,
|
item_id: 1,
|
||||||
codec: null,
|
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 {
|
return {
|
||||||
id: 0,
|
id: 0,
|
||||||
plan_id: 1,
|
plan_id: 1,
|
||||||
@@ -32,162 +32,178 @@ function decision(o: Partial<StreamDecision> & Pick<StreamDecision, 'stream_id'
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ITEM: MediaItem = {
|
const ITEM: MediaItem = {
|
||||||
id: 1, jellyfin_id: 'x', type: 'Movie', name: 'Test', series_name: null,
|
id: 1,
|
||||||
series_jellyfin_id: null, season_number: null, episode_number: null, year: null,
|
jellyfin_id: "x",
|
||||||
file_path: '/movies/Test.mkv', file_size: null, container: 'mkv',
|
type: "Movie",
|
||||||
original_language: 'eng', orig_lang_source: 'jellyfin', needs_review: 0,
|
name: "Test",
|
||||||
imdb_id: null, tmdb_id: null, tvdb_id: null, scan_status: 'scanned',
|
series_name: null,
|
||||||
scan_error: null, last_scanned_at: null, created_at: '',
|
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', () => {
|
describe("shellQuote", () => {
|
||||||
test('wraps plain strings in single quotes', () => {
|
test("wraps plain strings in single quotes", () => {
|
||||||
expect(shellQuote('hello')).toBe("'hello'");
|
expect(shellQuote("hello")).toBe("'hello'");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('escapes single quotes safely', () => {
|
test("escapes single quotes safely", () => {
|
||||||
expect(shellQuote("it's")).toBe("'it'\\''s'");
|
expect(shellQuote("it's")).toBe("'it'\\''s'");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles paths with spaces', () => {
|
test("handles paths with spaces", () => {
|
||||||
expect(shellQuote('/movies/My Movie.mkv')).toBe("'/movies/My Movie.mkv'");
|
expect(shellQuote("/movies/My Movie.mkv")).toBe("'/movies/My Movie.mkv'");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('sortKeptStreams', () => {
|
describe("sortKeptStreams", () => {
|
||||||
test('orders by type priority (Video, Audio, Subtitle, Data), then target_index', () => {
|
test("orders by type priority (Video, Audio, Subtitle, Data), then target_index", () => {
|
||||||
const streams = [
|
const streams = [
|
||||||
stream({ id: 1, type: 'Audio', stream_index: 1 }),
|
stream({ id: 1, type: "Audio", stream_index: 1 }),
|
||||||
stream({ id: 2, type: 'Video', stream_index: 0 }),
|
stream({ id: 2, type: "Video", stream_index: 0 }),
|
||||||
stream({ id: 3, type: 'Audio', stream_index: 2 }),
|
stream({ id: 3, type: "Audio", stream_index: 2 }),
|
||||||
];
|
];
|
||||||
const decisions = [
|
const decisions = [
|
||||||
decision({ stream_id: 1, action: 'keep', target_index: 1 }),
|
decision({ stream_id: 1, action: "keep", target_index: 1 }),
|
||||||
decision({ stream_id: 2, action: 'keep', target_index: 0 }),
|
decision({ stream_id: 2, action: "keep", target_index: 0 }),
|
||||||
decision({ stream_id: 3, action: 'keep', target_index: 0 }),
|
decision({ stream_id: 3, action: "keep", target_index: 0 }),
|
||||||
];
|
];
|
||||||
const sorted = sortKeptStreams(streams, decisions);
|
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', () => {
|
test("drops streams with action remove", () => {
|
||||||
const streams = [stream({ id: 1, type: 'Audio', stream_index: 0 })];
|
const streams = [stream({ id: 1, type: "Audio", stream_index: 0 })];
|
||||||
const decisions = [decision({ stream_id: 1, action: 'remove' })];
|
const decisions = [decision({ stream_id: 1, action: "remove" })];
|
||||||
expect(sortKeptStreams(streams, decisions)).toEqual([]);
|
expect(sortKeptStreams(streams, decisions)).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildCommand', () => {
|
describe("buildCommand", () => {
|
||||||
test('produces ffmpeg remux with tmp-rename pattern', () => {
|
test("produces ffmpeg remux with tmp-rename pattern", () => {
|
||||||
const streams = [
|
const streams = [
|
||||||
stream({ id: 1, type: 'Video', stream_index: 0, codec: 'h264' }),
|
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
|
||||||
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }),
|
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }),
|
||||||
];
|
];
|
||||||
const decisions = [
|
const decisions = [
|
||||||
decision({ stream_id: 1, action: 'keep', target_index: 0 }),
|
decision({ stream_id: 1, action: "keep", target_index: 0 }),
|
||||||
decision({ stream_id: 2, action: 'keep', target_index: 0 }),
|
decision({ stream_id: 2, action: "keep", target_index: 0 }),
|
||||||
];
|
];
|
||||||
const cmd = buildCommand(ITEM, streams, decisions);
|
const cmd = buildCommand(ITEM, streams, decisions);
|
||||||
expect(cmd).toContain('ffmpeg');
|
expect(cmd).toContain("ffmpeg");
|
||||||
expect(cmd).toContain('-map 0:v:0');
|
expect(cmd).toContain("-map 0:v:0");
|
||||||
expect(cmd).toContain('-map 0:a:0');
|
expect(cmd).toContain("-map 0:a:0");
|
||||||
expect(cmd).toContain('-c copy');
|
expect(cmd).toContain("-c copy");
|
||||||
expect(cmd).toContain("'/movies/Test.tmp.mkv'");
|
expect(cmd).toContain("'/movies/Test.tmp.mkv'");
|
||||||
expect(cmd).toContain("mv '/movies/Test.tmp.mkv' '/movies/Test.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 = [
|
const streams = [
|
||||||
stream({ id: 1, type: 'Video', stream_index: 0 }),
|
stream({ id: 1, type: "Video", stream_index: 0 }),
|
||||||
stream({ id: 2, type: 'Audio', stream_index: 1 }),
|
stream({ id: 2, type: "Audio", stream_index: 1 }),
|
||||||
stream({ id: 3, type: 'Audio', stream_index: 2 }),
|
stream({ id: 3, type: "Audio", stream_index: 2 }),
|
||||||
];
|
];
|
||||||
// Keep only the second audio; still mapped as 0:a:1
|
// Keep only the second audio; still mapped as 0:a:1
|
||||||
const decisions = [
|
const decisions = [
|
||||||
decision({ stream_id: 1, action: 'keep', target_index: 0 }),
|
decision({ stream_id: 1, action: "keep", target_index: 0 }),
|
||||||
decision({ stream_id: 2, action: 'remove' }),
|
decision({ stream_id: 2, action: "remove" }),
|
||||||
decision({ stream_id: 3, action: 'keep', target_index: 0 }),
|
decision({ stream_id: 3, action: "keep", target_index: 0 }),
|
||||||
];
|
];
|
||||||
const cmd = buildCommand(ITEM, streams, decisions);
|
const cmd = buildCommand(ITEM, streams, decisions);
|
||||||
expect(cmd).toContain('-map 0:a:1');
|
expect(cmd).toContain("-map 0:a:1");
|
||||||
expect(cmd).not.toContain('-map 0:a:2');
|
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 = [
|
const streams = [
|
||||||
stream({ id: 1, type: 'Video', stream_index: 0 }),
|
stream({ id: 1, type: "Video", stream_index: 0 }),
|
||||||
stream({ id: 2, type: 'Audio', stream_index: 1, language: 'eng' }),
|
stream({ id: 2, type: "Audio", stream_index: 1, language: "eng" }),
|
||||||
stream({ id: 3, type: 'Audio', stream_index: 2, language: 'deu' }),
|
stream({ id: 3, type: "Audio", stream_index: 2, language: "deu" }),
|
||||||
];
|
];
|
||||||
const decisions = [
|
const decisions = [
|
||||||
decision({ stream_id: 1, action: 'keep', target_index: 0 }),
|
decision({ stream_id: 1, action: "keep", target_index: 0 }),
|
||||||
decision({ stream_id: 2, 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: 3, action: "keep", target_index: 1 }),
|
||||||
];
|
];
|
||||||
const cmd = buildCommand(ITEM, streams, decisions);
|
const cmd = buildCommand(ITEM, streams, decisions);
|
||||||
expect(cmd).toContain('-disposition:a:0 default');
|
expect(cmd).toContain("-disposition:a:0 default");
|
||||||
expect(cmd).toContain('-disposition:a:1 0');
|
expect(cmd).toContain("-disposition:a:1 0");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildPipelineCommand', () => {
|
describe("buildPipelineCommand", () => {
|
||||||
test('emits subtitle extraction outputs and final remux in one pass', () => {
|
test("emits subtitle extraction outputs and final remux in one pass", () => {
|
||||||
const streams = [
|
const streams = [
|
||||||
stream({ id: 1, type: 'Video', stream_index: 0 }),
|
stream({ id: 1, type: "Video", stream_index: 0 }),
|
||||||
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'aac', language: 'eng' }),
|
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: 3, type: "Subtitle", stream_index: 2, codec: "subrip", language: "eng" }),
|
||||||
];
|
];
|
||||||
const decisions = [
|
const decisions = [
|
||||||
decision({ stream_id: 1, action: 'keep', target_index: 0 }),
|
decision({ stream_id: 1, action: "keep", target_index: 0 }),
|
||||||
decision({ stream_id: 2, action: 'keep', target_index: 0 }),
|
decision({ stream_id: 2, action: "keep", target_index: 0 }),
|
||||||
decision({ stream_id: 3, action: 'remove' }),
|
decision({ stream_id: 3, action: "remove" }),
|
||||||
];
|
];
|
||||||
const { command, extractedFiles } = buildPipelineCommand(ITEM, streams, decisions);
|
const { command, extractedFiles } = buildPipelineCommand(ITEM, streams, decisions);
|
||||||
expect(command).toContain('-map 0:s:0');
|
expect(command).toContain("-map 0:s:0");
|
||||||
expect(command).toContain('-c:s copy');
|
expect(command).toContain("-c:s copy");
|
||||||
expect(command).toContain("'/movies/Test.en.srt'");
|
expect(command).toContain("'/movies/Test.en.srt'");
|
||||||
expect(command).toContain('-map 0:v:0');
|
expect(command).toContain("-map 0:v:0");
|
||||||
expect(command).toContain('-map 0:a:0');
|
expect(command).toContain("-map 0:a:0");
|
||||||
expect(extractedFiles).toHaveLength(1);
|
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', () => {
|
test("transcodes incompatible audio with per-track codec flag", () => {
|
||||||
const dtsItem = { ...ITEM, container: 'mp4', file_path: '/movies/x.mp4' };
|
const dtsItem = { ...ITEM, container: "mp4", file_path: "/movies/x.mp4" };
|
||||||
const streams = [
|
const streams = [
|
||||||
stream({ id: 1, type: 'Video', stream_index: 0 }),
|
stream({ id: 1, type: "Video", stream_index: 0 }),
|
||||||
stream({ id: 2, type: 'Audio', stream_index: 1, codec: 'dts', language: 'eng', channels: 6 }),
|
stream({ id: 2, type: "Audio", stream_index: 1, codec: "dts", language: "eng", channels: 6 }),
|
||||||
];
|
];
|
||||||
const decisions = [
|
const decisions = [
|
||||||
decision({ stream_id: 1, action: 'keep', target_index: 0 }),
|
decision({ stream_id: 1, action: "keep", target_index: 0 }),
|
||||||
decision({ stream_id: 2, action: 'keep', target_index: 0, transcode_codec: 'eac3' }),
|
decision({ stream_id: 2, action: "keep", target_index: 0, transcode_codec: "eac3" }),
|
||||||
];
|
];
|
||||||
const { command } = buildPipelineCommand(dtsItem, streams, decisions);
|
const { command } = buildPipelineCommand(dtsItem, streams, decisions);
|
||||||
expect(command).toContain('-c:a:0 eac3');
|
expect(command).toContain("-c:a:0 eac3");
|
||||||
expect(command).toContain('-b:a:0 640k'); // 6 channels → 640k
|
expect(command).toContain("-b:a:0 640k"); // 6 channels → 640k
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('predictExtractedFiles', () => {
|
describe("predictExtractedFiles", () => {
|
||||||
test('predicts sidecar paths matching extraction output', () => {
|
test("predicts sidecar paths matching extraction output", () => {
|
||||||
const streams = [
|
const streams = [
|
||||||
stream({ id: 1, type: 'Subtitle', stream_index: 0, 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: 'deu', is_forced: 1 }),
|
stream({ id: 2, type: "Subtitle", stream_index: 1, codec: "subrip", language: "deu", is_forced: 1 }),
|
||||||
];
|
];
|
||||||
const files = predictExtractedFiles(ITEM, streams);
|
const files = predictExtractedFiles(ITEM, streams);
|
||||||
expect(files).toHaveLength(2);
|
expect(files).toHaveLength(2);
|
||||||
expect(files[0].file_path).toBe('/movies/Test.en.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].file_path).toBe("/movies/Test.de.forced.srt");
|
||||||
expect(files[1].is_forced).toBe(true);
|
expect(files[1].is_forced).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deduplicates paths with a numeric suffix', () => {
|
test("deduplicates paths with a numeric suffix", () => {
|
||||||
const streams = [
|
const streams = [
|
||||||
stream({ id: 1, type: 'Subtitle', stream_index: 0, 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' }),
|
stream({ id: 2, type: "Subtitle", stream_index: 1, codec: "subrip", language: "eng" }),
|
||||||
];
|
];
|
||||||
const files = predictExtractedFiles(ITEM, streams);
|
const files = predictExtractedFiles(ITEM, streams);
|
||||||
expect(files[0].file_path).toBe('/movies/Test.en.srt');
|
expect(files[0].file_path).toBe("/movies/Test.en.srt");
|
||||||
expect(files[1].file_path).toBe('/movies/Test.en.2.srt');
|
expect(files[1].file_path).toBe("/movies/Test.en.2.srt");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { MediaItem, MediaStream, PlanResult } from '../types';
|
import type { MediaItem, MediaStream, PlanResult } from "../types";
|
||||||
import { normalizeLanguage } from './jellyfin';
|
import { computeAppleCompat, transcodeTarget } from "./apple-compat";
|
||||||
import { transcodeTarget, computeAppleCompat } from './apple-compat';
|
import { normalizeLanguage } from "./jellyfin";
|
||||||
|
|
||||||
export interface AnalyzerConfig {
|
export interface AnalyzerConfig {
|
||||||
subtitleLanguages: string[];
|
subtitleLanguages: string[];
|
||||||
@@ -17,77 +17,73 @@ export interface AnalyzerConfig {
|
|||||||
* at all.
|
* at all.
|
||||||
*/
|
*/
|
||||||
export function analyzeItem(
|
export function analyzeItem(
|
||||||
item: Pick<MediaItem, 'original_language' | 'needs_review' | 'container'>,
|
item: Pick<MediaItem, "original_language" | "needs_review" | "container">,
|
||||||
streams: MediaStream[],
|
streams: MediaStream[],
|
||||||
config: AnalyzerConfig
|
config: AnalyzerConfig,
|
||||||
): PlanResult {
|
): PlanResult {
|
||||||
const origLang = item.original_language ? normalizeLanguage(item.original_language) : null;
|
const origLang = item.original_language ? normalizeLanguage(item.original_language) : null;
|
||||||
const notes: string[] = [];
|
const notes: string[] = [];
|
||||||
|
|
||||||
const decisions: PlanResult['decisions'] = streams.map((s) => {
|
const decisions: PlanResult["decisions"] = streams.map((s) => {
|
||||||
const action = decideAction(s, origLang, config.audioLanguages);
|
const action = decideAction(s, origLang, config.audioLanguages);
|
||||||
return { stream_id: s.id, action, target_index: null, transcode_codec: null };
|
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);
|
assignTargetOrder(streams, decisions, origLang, config.audioLanguages);
|
||||||
|
|
||||||
const audioOrderChanged = checkAudioOrderChanged(streams, decisions);
|
const audioOrderChanged = checkAudioOrderChanged(streams, decisions);
|
||||||
|
|
||||||
for (const d of decisions) {
|
for (const d of decisions) {
|
||||||
if (d.action !== 'keep') continue;
|
if (d.action !== "keep") continue;
|
||||||
const stream = streams.find(s => s.id === d.stream_id);
|
const stream = streams.find((s) => s.id === d.stream_id);
|
||||||
if (stream && stream.type === 'Audio') {
|
if (stream && stream.type === "Audio") {
|
||||||
d.transcode_codec = transcodeTarget(stream.codec ?? '', stream.title, item.container);
|
d.transcode_codec = transcodeTarget(stream.codec ?? "", stream.title, item.container);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const keptAudioCodecs = decisions
|
const keptAudioCodecs = decisions
|
||||||
.filter(d => d.action === 'keep')
|
.filter((d) => d.action === "keep")
|
||||||
.map(d => streams.find(s => s.id === d.stream_id))
|
.map((d) => streams.find((s) => s.id === d.stream_id))
|
||||||
.filter((s): s is MediaStream => !!s && s.type === 'Audio')
|
.filter((s): s is MediaStream => !!s && s.type === "Audio")
|
||||||
.map(s => s.codec ?? '');
|
.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 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;
|
const is_noop = !anyAudioRemoved && !audioOrderChanged && !hasSubs && !needsTranscode;
|
||||||
|
|
||||||
if (!origLang && item.needs_review) {
|
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(
|
function decideAction(stream: MediaStream, origLang: string | null, audioLanguages: string[]): "keep" | "remove" {
|
||||||
stream: MediaStream,
|
|
||||||
origLang: string | null,
|
|
||||||
audioLanguages: string[],
|
|
||||||
): 'keep' | 'remove' {
|
|
||||||
switch (stream.type) {
|
switch (stream.type) {
|
||||||
case 'Video':
|
case "Video":
|
||||||
case 'Data':
|
case "Data":
|
||||||
case 'EmbeddedImage':
|
case "EmbeddedImage":
|
||||||
return 'keep';
|
return "keep";
|
||||||
|
|
||||||
case 'Audio': {
|
case "Audio": {
|
||||||
if (!origLang) return 'keep';
|
if (!origLang) return "keep";
|
||||||
if (!stream.language) return 'keep';
|
if (!stream.language) return "keep";
|
||||||
const normalized = normalizeLanguage(stream.language);
|
const normalized = normalizeLanguage(stream.language);
|
||||||
if (normalized === origLang) return 'keep';
|
if (normalized === origLang) return "keep";
|
||||||
if (audioLanguages.includes(normalized)) return 'keep';
|
if (audioLanguages.includes(normalized)) return "keep";
|
||||||
return 'remove';
|
return "remove";
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'Subtitle':
|
case "Subtitle":
|
||||||
return 'remove';
|
return "remove";
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return 'keep';
|
return "keep";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,19 +95,19 @@ function decideAction(
|
|||||||
*/
|
*/
|
||||||
export function assignTargetOrder(
|
export function assignTargetOrder(
|
||||||
allStreams: MediaStream[],
|
allStreams: MediaStream[],
|
||||||
decisions: PlanResult['decisions'],
|
decisions: PlanResult["decisions"],
|
||||||
origLang: string | null,
|
origLang: string | null,
|
||||||
audioLanguages: string[],
|
audioLanguages: string[],
|
||||||
): void {
|
): void {
|
||||||
const keptByType = new Map<string, MediaStream[]>();
|
const keptByType = new Map<string, MediaStream[]>();
|
||||||
for (const s of allStreams) {
|
for (const s of allStreams) {
|
||||||
const dec = decisions.find(d => d.stream_id === s.id);
|
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||||
if (dec?.action !== 'keep') continue;
|
if (dec?.action !== "keep") continue;
|
||||||
if (!keptByType.has(s.type)) keptByType.set(s.type, []);
|
if (!keptByType.has(s.type)) keptByType.set(s.type, []);
|
||||||
keptByType.get(s.type)!.push(s);
|
keptByType.get(s.type)!.push(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
const audio = keptByType.get('Audio');
|
const audio = keptByType.get("Audio");
|
||||||
if (audio) {
|
if (audio) {
|
||||||
audio.sort((a, b) => {
|
audio.sort((a, b) => {
|
||||||
const aRank = langRank(a.language, origLang, audioLanguages);
|
const aRank = langRank(a.language, origLang, audioLanguages);
|
||||||
@@ -123,7 +119,7 @@ export function assignTargetOrder(
|
|||||||
|
|
||||||
for (const [, streams] of keptByType) {
|
for (const [, streams] of keptByType) {
|
||||||
streams.forEach((s, idx) => {
|
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;
|
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
|
* original order in the input. Compares original stream_index order
|
||||||
* against target_index order.
|
* against target_index order.
|
||||||
*/
|
*/
|
||||||
function checkAudioOrderChanged(
|
function checkAudioOrderChanged(streams: MediaStream[], decisions: PlanResult["decisions"]): boolean {
|
||||||
streams: MediaStream[],
|
|
||||||
decisions: PlanResult['decisions']
|
|
||||||
): boolean {
|
|
||||||
const keptAudio = streams
|
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);
|
.sort((a, b) => a.stream_index - b.stream_index);
|
||||||
|
|
||||||
for (let i = 0; i < keptAudio.length; i++) {
|
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;
|
if (dec?.target_index !== i) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -3,64 +3,67 @@
|
|||||||
// Everything else (DTS family, TrueHD family) needs transcoding.
|
// Everything else (DTS family, TrueHD family) needs transcoding.
|
||||||
|
|
||||||
const APPLE_COMPATIBLE_AUDIO = new Set([
|
const APPLE_COMPATIBLE_AUDIO = new Set([
|
||||||
'aac', 'ac3', 'eac3', 'alac', 'flac', 'mp3',
|
"aac",
|
||||||
'pcm_s16le', 'pcm_s24le', 'pcm_s32le', 'pcm_f32le',
|
"ac3",
|
||||||
'pcm_s16be', 'pcm_s24be', 'pcm_s32be', 'pcm_f64le',
|
"eac3",
|
||||||
'opus',
|
"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
|
// Codec strings Jellyfin may report for DTS variants
|
||||||
const DTS_CODECS = new Set([
|
const DTS_CODECS = new Set(["dts", "dca"]);
|
||||||
'dts', 'dca',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const TRUEHD_CODECS = new Set([
|
const TRUEHD_CODECS = new Set(["truehd"]);
|
||||||
'truehd',
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function isAppleCompatible(codec: string): boolean {
|
export function isAppleCompatible(codec: string): boolean {
|
||||||
return APPLE_COMPATIBLE_AUDIO.has(codec.toLowerCase());
|
return APPLE_COMPATIBLE_AUDIO.has(codec.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Maps (codec, profile, container) → target codec for transcoding. */
|
/** Maps (codec, profile, container) → target codec for transcoding. */
|
||||||
export function transcodeTarget(
|
export function transcodeTarget(codec: string, profile: string | null, container: string | null): string | null {
|
||||||
codec: string,
|
|
||||||
profile: string | null,
|
|
||||||
container: string | null,
|
|
||||||
): string | null {
|
|
||||||
const c = codec.toLowerCase();
|
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
|
if (isAppleCompatible(c)) return null; // no transcode needed
|
||||||
|
|
||||||
// DTS-HD MA and DTS:X are lossless → FLAC in MKV, EAC3 in MP4
|
// DTS-HD MA and DTS:X are lossless → FLAC in MKV, EAC3 in MP4
|
||||||
if (DTS_CODECS.has(c)) {
|
if (DTS_CODECS.has(c)) {
|
||||||
const p = (profile ?? '').toLowerCase();
|
const p = (profile ?? "").toLowerCase();
|
||||||
const isLossless = p.includes('ma') || p.includes('hd ma') || p.includes('x');
|
const isLossless = p.includes("ma") || p.includes("hd ma") || p.includes("x");
|
||||||
if (isLossless) return isMkv ? 'flac' : 'eac3';
|
if (isLossless) return isMkv ? "flac" : "eac3";
|
||||||
// Lossy DTS variants → EAC3
|
// Lossy DTS variants → EAC3
|
||||||
return 'eac3';
|
return "eac3";
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrueHD (including Atmos) → FLAC in MKV, EAC3 in MP4
|
// TrueHD (including Atmos) → FLAC in MKV, EAC3 in MP4
|
||||||
if (TRUEHD_CODECS.has(c)) {
|
if (TRUEHD_CODECS.has(c)) {
|
||||||
return isMkv ? 'flac' : 'eac3';
|
return isMkv ? "flac" : "eac3";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any other incompatible codec → EAC3 as safe fallback
|
// Any other incompatible codec → EAC3 as safe fallback
|
||||||
return 'eac3';
|
return "eac3";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Determine overall Apple compatibility for a set of kept audio streams. */
|
/** Determine overall Apple compatibility for a set of kept audio streams. */
|
||||||
export function computeAppleCompat(
|
export function computeAppleCompat(
|
||||||
keptAudioCodecs: string[],
|
keptAudioCodecs: string[],
|
||||||
container: string | null,
|
container: string | null,
|
||||||
): 'direct_play' | 'remux' | 'audio_transcode' {
|
): "direct_play" | "remux" | "audio_transcode" {
|
||||||
const hasIncompatible = keptAudioCodecs.some(c => !isAppleCompatible(c));
|
const hasIncompatible = keptAudioCodecs.some((c) => !isAppleCompatible(c));
|
||||||
if (hasIncompatible) return 'audio_transcode';
|
if (hasIncompatible) return "audio_transcode";
|
||||||
|
|
||||||
const isMkv = !container || container.toLowerCase() === 'mkv' || container.toLowerCase() === 'matroska';
|
const isMkv = !container || container.toLowerCase() === "mkv" || container.toLowerCase() === "matroska";
|
||||||
if (isMkv) return 'remux';
|
if (isMkv) return "remux";
|
||||||
|
|
||||||
return 'direct_play';
|
return "direct_play";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,83 @@
|
|||||||
import type { MediaItem, MediaStream, StreamDecision } from '../types';
|
import type { MediaItem, MediaStream, StreamDecision } from "../types";
|
||||||
import { normalizeLanguage } from './jellyfin';
|
import { normalizeLanguage } from "./jellyfin";
|
||||||
|
|
||||||
// ─── Subtitle extraction helpers ──────────────────────────────────────────────
|
// ─── Subtitle extraction helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
/** ISO 639-2/B → ISO 639-1 two-letter codes for subtitle filenames. */
|
/** ISO 639-2/B → ISO 639-1 two-letter codes for subtitle filenames. */
|
||||||
const ISO639_1: Record<string, string> = {
|
const ISO639_1: Record<string, string> = {
|
||||||
eng: 'en', deu: 'de', spa: 'es', fra: 'fr', ita: 'it',
|
eng: "en",
|
||||||
por: 'pt', jpn: 'ja', kor: 'ko', zho: 'zh', ara: 'ar',
|
deu: "de",
|
||||||
rus: 'ru', nld: 'nl', swe: 'sv', nor: 'no', dan: 'da',
|
spa: "es",
|
||||||
fin: 'fi', pol: 'pl', tur: 'tr', tha: 'th', hin: 'hi',
|
fra: "fr",
|
||||||
hun: 'hu', ces: 'cs', ron: 'ro', ell: 'el', heb: 'he',
|
ita: "it",
|
||||||
fas: 'fa', ukr: 'uk', ind: 'id', cat: 'ca', nob: 'nb',
|
por: "pt",
|
||||||
nno: 'nn', isl: 'is', hrv: 'hr', slk: 'sk', bul: 'bg',
|
jpn: "ja",
|
||||||
srp: 'sr', slv: 'sl', lav: 'lv', lit: 'lt', est: 'et',
|
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. */
|
/** Subtitle codec → external file extension. */
|
||||||
const SUBTITLE_EXT: Record<string, string> = {
|
const SUBTITLE_EXT: Record<string, string> = {
|
||||||
subrip: 'srt', srt: 'srt', ass: 'ass', ssa: 'ssa',
|
subrip: "srt",
|
||||||
webvtt: 'vtt', vtt: 'vtt',
|
srt: "srt",
|
||||||
hdmv_pgs_subtitle: 'sup', pgssub: 'sup',
|
ass: "ass",
|
||||||
dvd_subtitle: 'sub', dvbsub: 'sub',
|
ssa: "ssa",
|
||||||
mov_text: 'srt', text: 'srt',
|
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 {
|
function subtitleLang2(lang: string | null): string {
|
||||||
if (!lang) return 'und';
|
if (!lang) return "und";
|
||||||
const n = normalizeLanguage(lang);
|
const n = normalizeLanguage(lang);
|
||||||
return ISO639_1[n] ?? n;
|
return ISO639_1[n] ?? n;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the ffmpeg codec name to use when extracting this subtitle stream. */
|
/** Returns the ffmpeg codec name to use when extracting this subtitle stream. */
|
||||||
function subtitleCodecArg(codec: string | null): string {
|
function subtitleCodecArg(codec: string | null): string {
|
||||||
if (!codec) return 'copy';
|
if (!codec) return "copy";
|
||||||
return codec.toLowerCase() === 'mov_text' ? 'subrip' : 'copy';
|
return codec.toLowerCase() === "mov_text" ? "subrip" : "copy";
|
||||||
}
|
}
|
||||||
|
|
||||||
function subtitleExtForCodec(codec: string | null): string {
|
function subtitleExtForCodec(codec: string | null): string {
|
||||||
if (!codec) return 'srt';
|
if (!codec) return "srt";
|
||||||
return SUBTITLE_EXT[codec.toLowerCase()] ?? '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. */
|
/** Compute extraction metadata for all subtitle streams. Shared by buildExtractionOutputs and predictExtractedFiles. */
|
||||||
function computeExtractionEntries(
|
function computeExtractionEntries(allStreams: MediaStream[], basePath: string): ExtractionEntry[] {
|
||||||
allStreams: MediaStream[],
|
|
||||||
basePath: string
|
|
||||||
): ExtractionEntry[] {
|
|
||||||
const subTypeIdx = new Map<number, number>();
|
const subTypeIdx = new Map<number, number>();
|
||||||
let subCount = 0;
|
let subCount = 0;
|
||||||
for (const s of [...allStreams].sort((a, b) => a.stream_index - b.stream_index)) {
|
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
|
const allSubs = allStreams.filter((s) => s.type === "Subtitle").sort((a, b) => a.stream_index - b.stream_index);
|
||||||
.filter((s) => s.type === 'Subtitle')
|
|
||||||
.sort((a, b) => a.stream_index - b.stream_index);
|
|
||||||
|
|
||||||
if (allSubs.length === 0) return [];
|
if (allSubs.length === 0) return [];
|
||||||
|
|
||||||
@@ -86,13 +120,13 @@ function computeExtractionEntries(
|
|||||||
const codecArg = subtitleCodecArg(s.codec);
|
const codecArg = subtitleCodecArg(s.codec);
|
||||||
|
|
||||||
const nameParts = [langCode];
|
const nameParts = [langCode];
|
||||||
if (s.is_forced) nameParts.push('forced');
|
if (s.is_forced) nameParts.push("forced");
|
||||||
if (s.is_hearing_impaired) nameParts.push('hi');
|
if (s.is_hearing_impaired) nameParts.push("hi");
|
||||||
|
|
||||||
let outPath = `${basePath}.${nameParts.join('.')}.${ext}`;
|
let outPath = `${basePath}.${nameParts.join(".")}.${ext}`;
|
||||||
let counter = 2;
|
let counter = 2;
|
||||||
while (usedNames.has(outPath)) {
|
while (usedNames.has(outPath)) {
|
||||||
outPath = `${basePath}.${nameParts.join('.')}.${counter}.${ext}`;
|
outPath = `${basePath}.${nameParts.join(".")}.${counter}.${ext}`;
|
||||||
counter++;
|
counter++;
|
||||||
}
|
}
|
||||||
usedNames.add(outPath);
|
usedNames.add(outPath);
|
||||||
@@ -103,10 +137,7 @@ function computeExtractionEntries(
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildExtractionOutputs(
|
function buildExtractionOutputs(allStreams: MediaStream[], basePath: string): string[] {
|
||||||
allStreams: MediaStream[],
|
|
||||||
basePath: string
|
|
||||||
): string[] {
|
|
||||||
const entries = computeExtractionEntries(allStreams, basePath);
|
const entries = computeExtractionEntries(allStreams, basePath);
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
@@ -121,9 +152,15 @@ function buildExtractionOutputs(
|
|||||||
*/
|
*/
|
||||||
export function predictExtractedFiles(
|
export function predictExtractedFiles(
|
||||||
item: MediaItem,
|
item: MediaItem,
|
||||||
streams: MediaStream[]
|
streams: MediaStream[],
|
||||||
): Array<{ file_path: string; language: string | null; codec: string | null; is_forced: boolean; is_hearing_impaired: boolean }> {
|
): Array<{
|
||||||
const basePath = item.file_path.replace(/\.[^.]+$/, '');
|
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);
|
const entries = computeExtractionEntries(streams, basePath);
|
||||||
return entries.map((e) => ({
|
return entries.map((e) => ({
|
||||||
file_path: e.outPath,
|
file_path: e.outPath,
|
||||||
@@ -137,21 +174,50 @@ export function predictExtractedFiles(
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const LANG_NAMES: Record<string, string> = {
|
const LANG_NAMES: Record<string, string> = {
|
||||||
eng: 'English', deu: 'German', spa: 'Spanish', fra: 'French',
|
eng: "English",
|
||||||
ita: 'Italian', por: 'Portuguese', jpn: 'Japanese', kor: 'Korean',
|
deu: "German",
|
||||||
zho: 'Chinese', ara: 'Arabic', rus: 'Russian', nld: 'Dutch',
|
spa: "Spanish",
|
||||||
swe: 'Swedish', nor: 'Norwegian', dan: 'Danish', fin: 'Finnish',
|
fra: "French",
|
||||||
pol: 'Polish', tur: 'Turkish', tha: 'Thai', hin: 'Hindi',
|
ita: "Italian",
|
||||||
hun: 'Hungarian', ces: 'Czech', ron: 'Romanian', ell: 'Greek',
|
por: "Portuguese",
|
||||||
heb: 'Hebrew', fas: 'Persian', ukr: 'Ukrainian', ind: 'Indonesian',
|
jpn: "Japanese",
|
||||||
cat: 'Catalan', nob: 'Norwegian Bokmål', nno: 'Norwegian Nynorsk',
|
kor: "Korean",
|
||||||
isl: 'Icelandic', slk: 'Slovak', hrv: 'Croatian', bul: 'Bulgarian',
|
zho: "Chinese",
|
||||||
srp: 'Serbian', slv: 'Slovenian', lav: 'Latvian', lit: 'Lithuanian',
|
ara: "Arabic",
|
||||||
est: 'Estonian',
|
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 {
|
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
|
// Subtitles always get a clean language-based title so Jellyfin displays
|
||||||
// "German", "English (Forced)", etc. regardless of the original file title.
|
// "German", "English (Forced)", etc. regardless of the original file title.
|
||||||
// The review UI shows a ⚠ badge when the original title looks like a
|
// 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();
|
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).
|
* 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
|
* as attachments). Using the stream's position within its own type group
|
||||||
* matches ffmpeg's 0:a:N convention exactly and avoids silent mismatches.
|
* matches ffmpeg's 0:a:N convention exactly and avoids silent mismatches.
|
||||||
*/
|
*/
|
||||||
function buildMaps(
|
function buildMaps(allStreams: MediaStream[], kept: { stream: MediaStream; dec: StreamDecision }[]): string[] {
|
||||||
allStreams: MediaStream[],
|
|
||||||
kept: { stream: MediaStream; dec: StreamDecision }[]
|
|
||||||
): string[] {
|
|
||||||
// Map each stream id → its 0-based position among streams of the same type,
|
// 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).
|
// sorted by stream_index (the order ffmpeg sees them in the input).
|
||||||
const typePos = new Map<number, number>();
|
const typePos = new Map<number, number>();
|
||||||
@@ -206,15 +269,13 @@ function buildMaps(
|
|||||||
* - Marks the first kept audio stream as default, clears all others.
|
* - Marks the first kept audio stream as default, clears all others.
|
||||||
* - Sets harmonized language-name titles on all kept audio streams.
|
* - Sets harmonized language-name titles on all kept audio streams.
|
||||||
*/
|
*/
|
||||||
function buildStreamFlags(
|
function buildStreamFlags(kept: { stream: MediaStream; dec: StreamDecision }[]): string[] {
|
||||||
kept: { stream: MediaStream; dec: StreamDecision }[]
|
const audioKept = kept.filter((k) => k.stream.type === "Audio");
|
||||||
): string[] {
|
|
||||||
const audioKept = kept.filter((k) => k.stream.type === 'Audio');
|
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
|
|
||||||
// Disposition: first audio = default, rest = clear
|
// Disposition: first audio = default, rest = clear
|
||||||
audioKept.forEach((_, i) => {
|
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)
|
// 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(
|
export function sortKeptStreams(
|
||||||
streams: MediaStream[],
|
streams: MediaStream[],
|
||||||
decisions: StreamDecision[]
|
decisions: StreamDecision[],
|
||||||
): { stream: MediaStream; dec: StreamDecision }[] {
|
): { stream: MediaStream; dec: StreamDecision }[] {
|
||||||
const kept: { stream: MediaStream; dec: StreamDecision }[] = [];
|
const kept: { stream: MediaStream; dec: StreamDecision }[] = [];
|
||||||
for (const s of streams) {
|
for (const s of streams) {
|
||||||
const dec = decisions.find(d => d.stream_id === s.id);
|
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||||
if (dec?.action === 'keep') kept.push({ stream: s, dec });
|
if (dec?.action === "keep") kept.push({ stream: s, dec });
|
||||||
}
|
}
|
||||||
kept.sort((a, b) => {
|
kept.sort((a, b) => {
|
||||||
const ta = TYPE_ORDER[a.stream.type] ?? 9;
|
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).
|
* Returns null if all streams are kept and ordering is unchanged (noop).
|
||||||
*/
|
*/
|
||||||
export function buildCommand(
|
export function buildCommand(item: MediaItem, streams: MediaStream[], decisions: StreamDecision[]): string {
|
||||||
item: MediaItem,
|
|
||||||
streams: MediaStream[],
|
|
||||||
decisions: StreamDecision[]
|
|
||||||
): string {
|
|
||||||
const kept = sortKeptStreams(streams, decisions);
|
const kept = sortKeptStreams(streams, decisions);
|
||||||
|
|
||||||
const inputPath = item.file_path;
|
const inputPath = item.file_path;
|
||||||
const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv';
|
const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? "mkv";
|
||||||
const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
|
const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
|
||||||
|
|
||||||
const maps = buildMaps(streams, kept);
|
const maps = buildMaps(streams, kept);
|
||||||
const streamFlags = buildStreamFlags(kept);
|
const streamFlags = buildStreamFlags(kept);
|
||||||
|
|
||||||
const parts: string[] = [
|
const parts: string[] = [
|
||||||
'ffmpeg',
|
"ffmpeg",
|
||||||
'-y',
|
"-y",
|
||||||
'-i', shellQuote(inputPath),
|
"-i",
|
||||||
|
shellQuote(inputPath),
|
||||||
...maps,
|
...maps,
|
||||||
...streamFlags,
|
...streamFlags,
|
||||||
'-c copy',
|
"-c copy",
|
||||||
shellQuote(tmpPath),
|
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.
|
* Build a command that also changes the container to MKV.
|
||||||
* Used when MP4 container can't hold certain subtitle codecs.
|
* Used when MP4 container can't hold certain subtitle codecs.
|
||||||
*/
|
*/
|
||||||
export function buildMkvConvertCommand(
|
export function buildMkvConvertCommand(item: MediaItem, streams: MediaStream[], decisions: StreamDecision[]): string {
|
||||||
item: MediaItem,
|
|
||||||
streams: MediaStream[],
|
|
||||||
decisions: StreamDecision[]
|
|
||||||
): string {
|
|
||||||
const inputPath = item.file_path;
|
const inputPath = item.file_path;
|
||||||
const outputPath = inputPath.replace(/\.[^.]+$/, '.mkv');
|
const outputPath = inputPath.replace(/\.[^.]+$/, ".mkv");
|
||||||
const tmpPath = inputPath.replace(/\.[^.]+$/, '.tmp.mkv');
|
const tmpPath = inputPath.replace(/\.[^.]+$/, ".tmp.mkv");
|
||||||
|
|
||||||
const kept = sortKeptStreams(streams, decisions);
|
const kept = sortKeptStreams(streams, decisions);
|
||||||
|
|
||||||
@@ -306,16 +362,20 @@ export function buildMkvConvertCommand(
|
|||||||
const streamFlags = buildStreamFlags(kept);
|
const streamFlags = buildStreamFlags(kept);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'ffmpeg', '-y',
|
"ffmpeg",
|
||||||
'-i', shellQuote(inputPath),
|
"-y",
|
||||||
|
"-i",
|
||||||
|
shellQuote(inputPath),
|
||||||
...maps,
|
...maps,
|
||||||
...streamFlags,
|
...streamFlags,
|
||||||
'-c copy',
|
"-c copy",
|
||||||
'-f matroska',
|
"-f matroska",
|
||||||
shellQuote(tmpPath),
|
shellQuote(tmpPath),
|
||||||
'&&',
|
"&&",
|
||||||
'mv', shellQuote(tmpPath), shellQuote(outputPath),
|
"mv",
|
||||||
].join(' ');
|
shellQuote(tmpPath),
|
||||||
|
shellQuote(outputPath),
|
||||||
|
].join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -326,37 +386,38 @@ export function buildMkvConvertCommand(
|
|||||||
* track to its own sidecar file, then the final output copies all
|
* track to its own sidecar file, then the final output copies all
|
||||||
* video + audio streams into a temp file without subtitles.
|
* video + audio streams into a temp file without subtitles.
|
||||||
*/
|
*/
|
||||||
export function buildExtractOnlyCommand(
|
export function buildExtractOnlyCommand(item: MediaItem, streams: MediaStream[]): string | null {
|
||||||
item: MediaItem,
|
const basePath = item.file_path.replace(/\.[^.]+$/, "");
|
||||||
streams: MediaStream[]
|
|
||||||
): string | null {
|
|
||||||
const basePath = item.file_path.replace(/\.[^.]+$/, '');
|
|
||||||
const extractionOutputs = buildExtractionOutputs(streams, basePath);
|
const extractionOutputs = buildExtractionOutputs(streams, basePath);
|
||||||
if (extractionOutputs.length === 0) return null;
|
if (extractionOutputs.length === 0) return null;
|
||||||
|
|
||||||
const inputPath = item.file_path;
|
const inputPath = item.file_path;
|
||||||
const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv';
|
const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? "mkv";
|
||||||
const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
|
const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
|
||||||
|
|
||||||
// Only map audio if the file actually has audio streams
|
// Only map audio if the file actually has audio streams
|
||||||
const hasAudio = streams.some((s) => s.type === 'Audio');
|
const hasAudio = streams.some((s) => s.type === "Audio");
|
||||||
const remuxMaps = hasAudio ? ['-map 0:v', '-map 0:a'] : ['-map 0:v'];
|
const remuxMaps = hasAudio ? ["-map 0:v", "-map 0:a"] : ["-map 0:v"];
|
||||||
|
|
||||||
// Single ffmpeg pass: extract sidecar files + remux without subtitles
|
// Single ffmpeg pass: extract sidecar files + remux without subtitles
|
||||||
const parts: string[] = [
|
const parts: string[] = [
|
||||||
'ffmpeg', '-y',
|
"ffmpeg",
|
||||||
'-i', shellQuote(inputPath),
|
"-y",
|
||||||
|
"-i",
|
||||||
|
shellQuote(inputPath),
|
||||||
// Subtitle extraction outputs (each to its own file)
|
// Subtitle extraction outputs (each to its own file)
|
||||||
...extractionOutputs,
|
...extractionOutputs,
|
||||||
// Final output: copy all video + audio, no subtitles
|
// Final output: copy all video + audio, no subtitles
|
||||||
...remuxMaps,
|
...remuxMaps,
|
||||||
'-c copy',
|
"-c copy",
|
||||||
shellQuote(tmpPath),
|
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(
|
export function buildPipelineCommand(
|
||||||
item: MediaItem,
|
item: MediaItem,
|
||||||
streams: MediaStream[],
|
streams: MediaStream[],
|
||||||
decisions: (StreamDecision & { stream?: MediaStream })[]
|
decisions: (StreamDecision & { stream?: MediaStream })[],
|
||||||
): { command: string; extractedFiles: Array<{ path: string; language: string | null; codec: string | null; is_forced: number; is_hearing_impaired: number }> } {
|
): {
|
||||||
|
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 inputPath = item.file_path;
|
||||||
const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv';
|
const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? "mkv";
|
||||||
const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
|
const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
|
||||||
const basePath = inputPath.replace(/\.[^.]+$/, '');
|
const basePath = inputPath.replace(/\.[^.]+$/, "");
|
||||||
|
|
||||||
// --- Subtitle extraction outputs ---
|
// --- Subtitle extraction outputs ---
|
||||||
const extractionEntries = computeExtractionEntries(streams, basePath);
|
const extractionEntries = computeExtractionEntries(streams, basePath);
|
||||||
@@ -384,21 +454,21 @@ export function buildPipelineCommand(
|
|||||||
|
|
||||||
// --- Kept streams for remuxed output ---
|
// --- Kept streams for remuxed output ---
|
||||||
const kept = sortKeptStreams(streams, decisions as StreamDecision[]);
|
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
|
// Build -map flags
|
||||||
const maps = buildMaps(streams, kept);
|
const maps = buildMaps(streams, kept);
|
||||||
|
|
||||||
// Build per-stream codec flags
|
// Build per-stream codec flags
|
||||||
const codecFlags: string[] = ['-c:v copy'];
|
const codecFlags: string[] = ["-c:v copy"];
|
||||||
let audioIdx = 0;
|
let audioIdx = 0;
|
||||||
for (const d of enriched) {
|
for (const d of enriched) {
|
||||||
if (d.stream.type === 'Audio') {
|
if (d.stream.type === "Audio") {
|
||||||
if (d.transcode_codec) {
|
if (d.transcode_codec) {
|
||||||
codecFlags.push(`-c:a:${audioIdx} ${d.transcode_codec}`);
|
codecFlags.push(`-c:a:${audioIdx} ${d.transcode_codec}`);
|
||||||
// For EAC3, set a reasonable bitrate based on channel count
|
// For EAC3, set a reasonable bitrate based on channel count
|
||||||
if (d.transcode_codec === 'eac3') {
|
if (d.transcode_codec === "eac3") {
|
||||||
const bitrate = (d.stream.channels ?? 2) >= 6 ? '640k' : '256k';
|
const bitrate = (d.stream.channels ?? 2) >= 6 ? "640k" : "256k";
|
||||||
codecFlags.push(`-b:a:${audioIdx} ${bitrate}`);
|
codecFlags.push(`-b:a:${audioIdx} ${bitrate}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -409,17 +479,14 @@ export function buildPipelineCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If no audio transcoding, simplify to -c copy (covers video + audio)
|
// If no audio transcoding, simplify to -c copy (covers video + audio)
|
||||||
const hasTranscode = enriched.some(d => d.transcode_codec);
|
const hasTranscode = enriched.some((d) => d.transcode_codec);
|
||||||
const finalCodecFlags = hasTranscode ? codecFlags : ['-c copy'];
|
const finalCodecFlags = hasTranscode ? codecFlags : ["-c copy"];
|
||||||
|
|
||||||
// Disposition + metadata flags for audio
|
// Disposition + metadata flags for audio
|
||||||
const streamFlags = buildStreamFlags(kept);
|
const streamFlags = buildStreamFlags(kept);
|
||||||
|
|
||||||
// Assemble command
|
// Assemble command
|
||||||
const parts: string[] = [
|
const parts: string[] = ["ffmpeg", "-y", "-i", shellQuote(inputPath)];
|
||||||
'ffmpeg', '-y',
|
|
||||||
'-i', shellQuote(inputPath),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Subtitle extraction outputs first
|
// Subtitle extraction outputs first
|
||||||
parts.push(...subOutputArgs);
|
parts.push(...subOutputArgs);
|
||||||
@@ -436,12 +503,11 @@ export function buildPipelineCommand(
|
|||||||
// Output file
|
// Output file
|
||||||
parts.push(shellQuote(tmpPath));
|
parts.push(shellQuote(tmpPath));
|
||||||
|
|
||||||
const command = parts.join(' ')
|
const command = `${parts.join(" ")} && mv ${shellQuote(tmpPath)} ${shellQuote(inputPath)}`;
|
||||||
+ ` && mv ${shellQuote(tmpPath)} ${shellQuote(inputPath)}`;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
command,
|
command,
|
||||||
extractedFiles: extractionEntries.map(e => ({
|
extractedFiles: extractionEntries.map((e) => ({
|
||||||
path: e.outPath,
|
path: e.outPath,
|
||||||
language: e.stream.language,
|
language: e.stream.language,
|
||||||
codec: e.stream.codec,
|
codec: e.stream.codec,
|
||||||
@@ -459,13 +525,13 @@ export function shellQuote(s: string): string {
|
|||||||
/** Returns a human-readable summary of what will change. */
|
/** Returns a human-readable summary of what will change. */
|
||||||
export function summarizeChanges(
|
export function summarizeChanges(
|
||||||
streams: MediaStream[],
|
streams: MediaStream[],
|
||||||
decisions: StreamDecision[]
|
decisions: StreamDecision[],
|
||||||
): { removed: MediaStream[]; kept: MediaStream[] } {
|
): { removed: MediaStream[]; kept: MediaStream[] } {
|
||||||
const removed: MediaStream[] = [];
|
const removed: MediaStream[] = [];
|
||||||
const kept: MediaStream[] = [];
|
const kept: MediaStream[] = [];
|
||||||
for (const s of streams) {
|
for (const s of streams) {
|
||||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
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);
|
else kept.push(s);
|
||||||
}
|
}
|
||||||
return { removed, kept };
|
return { removed, kept };
|
||||||
@@ -477,8 +543,8 @@ export function streamLabel(s: MediaStream): string {
|
|||||||
if (s.codec) parts.push(s.codec);
|
if (s.codec) parts.push(s.codec);
|
||||||
if (s.language_display || s.language) parts.push(s.language_display ?? s.language!);
|
if (s.language_display || s.language) parts.push(s.language_display ?? s.language!);
|
||||||
if (s.title) parts.push(`"${s.title}"`);
|
if (s.title) parts.push(`"${s.title}"`);
|
||||||
if (s.type === 'Audio' && s.channels) parts.push(`${s.channels}ch`);
|
if (s.type === "Audio" && s.channels) parts.push(`${s.channels}ch`);
|
||||||
if (s.is_forced) parts.push('forced');
|
if (s.is_forced) parts.push("forced");
|
||||||
if (s.is_hearing_impaired) parts.push('CC');
|
if (s.is_hearing_impaired) parts.push("CC");
|
||||||
return parts.join(' · ');
|
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 {
|
export interface JellyfinConfig {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -16,8 +16,8 @@ const PAGE_SIZE = 200;
|
|||||||
|
|
||||||
function headers(apiKey: string): Record<string, string> {
|
function headers(apiKey: string): Record<string, string> {
|
||||||
return {
|
return {
|
||||||
'X-Emby-Token': apiKey,
|
"X-Emby-Token": apiKey,
|
||||||
'Content-Type': 'application/json',
|
"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) });
|
const res = await fetch(`${cfg.url}/Users`, { headers: headers(cfg.apiKey) });
|
||||||
if (!res.ok) throw new Error(`Jellyfin /Users failed: ${res.status}`);
|
if (!res.ok) throw new Error(`Jellyfin /Users failed: ${res.status}`);
|
||||||
return res.json() as Promise<JellyfinUser[]>;
|
return res.json() as Promise<JellyfinUser[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ITEM_FIELDS = [
|
const ITEM_FIELDS = [
|
||||||
'MediaStreams',
|
"MediaStreams",
|
||||||
'Path',
|
"Path",
|
||||||
'ProviderIds',
|
"ProviderIds",
|
||||||
'OriginalTitle',
|
"OriginalTitle",
|
||||||
'ProductionYear',
|
"ProductionYear",
|
||||||
'Size',
|
"Size",
|
||||||
'Container',
|
"Container",
|
||||||
].join(',');
|
].join(",");
|
||||||
|
|
||||||
export async function* getAllItems(
|
export async function* getAllItems(
|
||||||
cfg: JellyfinConfig,
|
cfg: JellyfinConfig,
|
||||||
onProgress?: (count: number, total: number) => void
|
onProgress?: (count: number, total: number) => void,
|
||||||
): AsyncGenerator<JellyfinItem> {
|
): AsyncGenerator<JellyfinItem> {
|
||||||
let startIndex = 0;
|
let startIndex = 0;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
|
||||||
do {
|
do {
|
||||||
const url = new URL(itemsBaseUrl(cfg));
|
const url = new URL(itemsBaseUrl(cfg));
|
||||||
url.searchParams.set('Recursive', 'true');
|
url.searchParams.set("Recursive", "true");
|
||||||
url.searchParams.set('IncludeItemTypes', 'Movie,Episode');
|
url.searchParams.set("IncludeItemTypes", "Movie,Episode");
|
||||||
url.searchParams.set('Fields', ITEM_FIELDS);
|
url.searchParams.set("Fields", ITEM_FIELDS);
|
||||||
url.searchParams.set('Limit', String(PAGE_SIZE));
|
url.searchParams.set("Limit", String(PAGE_SIZE));
|
||||||
url.searchParams.set('StartIndex', String(startIndex));
|
url.searchParams.set("StartIndex", String(startIndex));
|
||||||
|
|
||||||
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
|
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
|
||||||
if (!res.ok) throw new Error(`Jellyfin items failed: ${res.status}`);
|
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> {
|
export async function* getDevItems(cfg: JellyfinConfig): AsyncGenerator<JellyfinItem> {
|
||||||
// 50 random movies
|
// 50 random movies
|
||||||
const movieUrl = new URL(itemsBaseUrl(cfg));
|
const movieUrl = new URL(itemsBaseUrl(cfg));
|
||||||
movieUrl.searchParams.set('Recursive', 'true');
|
movieUrl.searchParams.set("Recursive", "true");
|
||||||
movieUrl.searchParams.set('IncludeItemTypes', 'Movie');
|
movieUrl.searchParams.set("IncludeItemTypes", "Movie");
|
||||||
movieUrl.searchParams.set('SortBy', 'Random');
|
movieUrl.searchParams.set("SortBy", "Random");
|
||||||
movieUrl.searchParams.set('Limit', '50');
|
movieUrl.searchParams.set("Limit", "50");
|
||||||
movieUrl.searchParams.set('Fields', ITEM_FIELDS);
|
movieUrl.searchParams.set("Fields", ITEM_FIELDS);
|
||||||
|
|
||||||
const movieRes = await fetch(movieUrl.toString(), { headers: headers(cfg.apiKey) });
|
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[] };
|
const movieBody = (await movieRes.json()) as { Items: JellyfinItem[] };
|
||||||
for (const item of movieBody.Items) yield item;
|
for (const item of movieBody.Items) yield item;
|
||||||
|
|
||||||
// 10 random series → yield all their episodes
|
// 10 random series → yield all their episodes
|
||||||
const seriesUrl = new URL(itemsBaseUrl(cfg));
|
const seriesUrl = new URL(itemsBaseUrl(cfg));
|
||||||
seriesUrl.searchParams.set('Recursive', 'true');
|
seriesUrl.searchParams.set("Recursive", "true");
|
||||||
seriesUrl.searchParams.set('IncludeItemTypes', 'Series');
|
seriesUrl.searchParams.set("IncludeItemTypes", "Series");
|
||||||
seriesUrl.searchParams.set('SortBy', 'Random');
|
seriesUrl.searchParams.set("SortBy", "Random");
|
||||||
seriesUrl.searchParams.set('Limit', '10');
|
seriesUrl.searchParams.set("Limit", "10");
|
||||||
|
|
||||||
const seriesRes = await fetch(seriesUrl.toString(), { headers: headers(cfg.apiKey) });
|
const seriesRes = await fetch(seriesUrl.toString(), { headers: headers(cfg.apiKey) });
|
||||||
if (!seriesRes.ok) throw new Error(`Jellyfin series failed: HTTP ${seriesRes.status}`);
|
if (!seriesRes.ok) throw new Error(`Jellyfin series failed: HTTP ${seriesRes.status}`);
|
||||||
const seriesBody = (await seriesRes.json()) as { Items: Array<{ Id: string }> };
|
const seriesBody = (await seriesRes.json()) as { Items: Array<{ Id: string }> };
|
||||||
for (const series of seriesBody.Items) {
|
for (const series of seriesBody.Items) {
|
||||||
const epUrl = new URL(itemsBaseUrl(cfg));
|
const epUrl = new URL(itemsBaseUrl(cfg));
|
||||||
epUrl.searchParams.set('ParentId', series.Id);
|
epUrl.searchParams.set("ParentId", series.Id);
|
||||||
epUrl.searchParams.set('Recursive', 'true');
|
epUrl.searchParams.set("Recursive", "true");
|
||||||
epUrl.searchParams.set('IncludeItemTypes', 'Episode');
|
epUrl.searchParams.set("IncludeItemTypes", "Episode");
|
||||||
epUrl.searchParams.set('Fields', ITEM_FIELDS);
|
epUrl.searchParams.set("Fields", ITEM_FIELDS);
|
||||||
|
|
||||||
const epRes = await fetch(epUrl.toString(), { headers: headers(cfg.apiKey) });
|
const epRes = await fetch(epUrl.toString(), { headers: headers(cfg.apiKey) });
|
||||||
if (epRes.ok) {
|
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> {
|
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 base = cfg.userId ? `${cfg.url}/Users/${cfg.userId}/Items/${jellyfinId}` : `${cfg.url}/Items/${jellyfinId}`;
|
||||||
const url = new URL(base);
|
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) });
|
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
return res.json() as Promise<JellyfinItem>;
|
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)
|
// 2. Trigger refresh (returns 204 immediately; refresh runs async)
|
||||||
const refreshUrl = new URL(`${itemUrl}/Refresh`);
|
const refreshUrl = new URL(`${itemUrl}/Refresh`);
|
||||||
refreshUrl.searchParams.set('MetadataRefreshMode', 'FullRefresh');
|
refreshUrl.searchParams.set("MetadataRefreshMode", "FullRefresh");
|
||||||
refreshUrl.searchParams.set('ImageRefreshMode', 'None');
|
refreshUrl.searchParams.set("ImageRefreshMode", "None");
|
||||||
refreshUrl.searchParams.set('ReplaceAllMetadata', 'false');
|
refreshUrl.searchParams.set("ReplaceAllMetadata", "false");
|
||||||
refreshUrl.searchParams.set('ReplaceAllImages', 'false');
|
refreshUrl.searchParams.set("ReplaceAllImages", "false");
|
||||||
const refreshRes = await fetch(refreshUrl.toString(), { method: 'POST', headers: headers(cfg.apiKey) });
|
const refreshRes = await fetch(refreshUrl.toString(), { method: "POST", headers: headers(cfg.apiKey) });
|
||||||
if (!refreshRes.ok) throw new Error(`Jellyfin refresh failed: HTTP ${refreshRes.status}`);
|
if (!refreshRes.ok) throw new Error(`Jellyfin refresh failed: HTTP ${refreshRes.status}`);
|
||||||
|
|
||||||
// 3. Poll until DateLastRefreshed changes
|
// 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.
|
// Jellyfin doesn't have a direct "original_language" field like TMDb.
|
||||||
// The best proxy is the language of the first audio stream.
|
// The best proxy is the language of the first audio stream.
|
||||||
if (!item.MediaStreams) return null;
|
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;
|
return firstAudio?.Language ? normalizeLanguage(firstAudio.Language) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map a Jellyfin MediaStream to our internal MediaStream shape (sans id/item_id). */
|
/** 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 {
|
return {
|
||||||
stream_index: s.Index,
|
stream_index: s.Index,
|
||||||
type: s.Type as MediaStream['type'],
|
type: s.Type as MediaStream["type"],
|
||||||
codec: s.Codec ?? null,
|
codec: s.Codec ?? null,
|
||||||
language: s.Language ? normalizeLanguage(s.Language) : null,
|
language: s.Language ? normalizeLanguage(s.Language) : null,
|
||||||
language_display: s.DisplayLanguage ?? 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
|
// ISO 639-2/T → ISO 639-2/B normalization + common aliases
|
||||||
const LANG_ALIASES: Record<string, string> = {
|
const LANG_ALIASES: Record<string, string> = {
|
||||||
// German: both /T (deu) and /B (ger) → deu
|
// German: both /T (deu) and /B (ger) → deu
|
||||||
ger: 'deu',
|
ger: "deu",
|
||||||
// Chinese
|
// Chinese
|
||||||
chi: 'zho',
|
chi: "zho",
|
||||||
// French
|
// French
|
||||||
fre: 'fra',
|
fre: "fra",
|
||||||
// Dutch
|
// Dutch
|
||||||
dut: 'nld',
|
dut: "nld",
|
||||||
// Modern Greek
|
// Modern Greek
|
||||||
gre: 'ell',
|
gre: "ell",
|
||||||
// Hebrew
|
// Hebrew
|
||||||
heb: 'heb',
|
heb: "heb",
|
||||||
// Farsi
|
// Farsi
|
||||||
per: 'fas',
|
per: "fas",
|
||||||
// Romanian
|
// Romanian
|
||||||
rum: 'ron',
|
rum: "ron",
|
||||||
// Malay
|
// Malay
|
||||||
may: 'msa',
|
may: "msa",
|
||||||
// Tibetan
|
// Tibetan
|
||||||
tib: 'bod',
|
tib: "bod",
|
||||||
// Burmese
|
// Burmese
|
||||||
bur: 'mya',
|
bur: "mya",
|
||||||
// Czech
|
// Czech
|
||||||
cze: 'ces',
|
cze: "ces",
|
||||||
// Slovak
|
// Slovak
|
||||||
slo: 'slk',
|
slo: "slk",
|
||||||
// Georgian
|
// Georgian
|
||||||
geo: 'kat',
|
geo: "kat",
|
||||||
// Icelandic
|
// Icelandic
|
||||||
ice: 'isl',
|
ice: "isl",
|
||||||
// Armenian
|
// Armenian
|
||||||
arm: 'hye',
|
arm: "hye",
|
||||||
// Basque
|
// Basque
|
||||||
baq: 'eus',
|
baq: "eus",
|
||||||
// Albanian
|
// Albanian
|
||||||
alb: 'sqi',
|
alb: "sqi",
|
||||||
// Macedonian
|
// Macedonian
|
||||||
mac: 'mkd',
|
mac: "mkd",
|
||||||
// Welsh
|
// Welsh
|
||||||
wel: 'cym',
|
wel: "cym",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function normalizeLanguage(lang: string): string {
|
export function normalizeLanguage(lang: string): string {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { normalizeLanguage } from './jellyfin';
|
import { normalizeLanguage } from "./jellyfin";
|
||||||
|
|
||||||
export interface RadarrConfig {
|
export interface RadarrConfig {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -6,7 +6,7 @@ export interface RadarrConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function headers(apiKey: string): Record<string, string> {
|
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 }> {
|
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. */
|
/** Returns ISO 639-2 original language or null. */
|
||||||
export async function getOriginalLanguage(
|
export async function getOriginalLanguage(
|
||||||
cfg: RadarrConfig,
|
cfg: RadarrConfig,
|
||||||
ids: { tmdbId?: string; imdbId?: string }
|
ids: { tmdbId?: string; imdbId?: string },
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
let movie: RadarrMovie | null = null;
|
let movie: RadarrMovie | null = null;
|
||||||
@@ -65,41 +65,41 @@ export async function getOriginalLanguage(
|
|||||||
// Radarr returns language names like "English", "French", "German", etc.
|
// Radarr returns language names like "English", "French", "German", etc.
|
||||||
// Map them to ISO 639-2 codes.
|
// Map them to ISO 639-2 codes.
|
||||||
const NAME_TO_639_2: Record<string, string> = {
|
const NAME_TO_639_2: Record<string, string> = {
|
||||||
english: 'eng',
|
english: "eng",
|
||||||
french: 'fra',
|
french: "fra",
|
||||||
german: 'deu',
|
german: "deu",
|
||||||
spanish: 'spa',
|
spanish: "spa",
|
||||||
italian: 'ita',
|
italian: "ita",
|
||||||
portuguese: 'por',
|
portuguese: "por",
|
||||||
japanese: 'jpn',
|
japanese: "jpn",
|
||||||
korean: 'kor',
|
korean: "kor",
|
||||||
chinese: 'zho',
|
chinese: "zho",
|
||||||
arabic: 'ara',
|
arabic: "ara",
|
||||||
russian: 'rus',
|
russian: "rus",
|
||||||
dutch: 'nld',
|
dutch: "nld",
|
||||||
swedish: 'swe',
|
swedish: "swe",
|
||||||
norwegian: 'nor',
|
norwegian: "nor",
|
||||||
danish: 'dan',
|
danish: "dan",
|
||||||
finnish: 'fin',
|
finnish: "fin",
|
||||||
polish: 'pol',
|
polish: "pol",
|
||||||
turkish: 'tur',
|
turkish: "tur",
|
||||||
thai: 'tha',
|
thai: "tha",
|
||||||
hindi: 'hin',
|
hindi: "hin",
|
||||||
hungarian: 'hun',
|
hungarian: "hun",
|
||||||
czech: 'ces',
|
czech: "ces",
|
||||||
romanian: 'ron',
|
romanian: "ron",
|
||||||
greek: 'ell',
|
greek: "ell",
|
||||||
hebrew: 'heb',
|
hebrew: "heb",
|
||||||
persian: 'fas',
|
persian: "fas",
|
||||||
ukrainian: 'ukr',
|
ukrainian: "ukr",
|
||||||
indonesian: 'ind',
|
indonesian: "ind",
|
||||||
malay: 'msa',
|
malay: "msa",
|
||||||
vietnamese: 'vie',
|
vietnamese: "vie",
|
||||||
catalan: 'cat',
|
catalan: "cat",
|
||||||
tamil: 'tam',
|
tamil: "tam",
|
||||||
telugu: 'tel',
|
telugu: "tel",
|
||||||
'brazilian portuguese': 'por',
|
"brazilian portuguese": "por",
|
||||||
'portuguese (brazil)': 'por',
|
"portuguese (brazil)": "por",
|
||||||
};
|
};
|
||||||
|
|
||||||
function iso6391To6392(name: string): string | null {
|
function iso6391To6392(name: string): string | null {
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
import { getConfig, setConfig } from '../db';
|
import { getConfig, setConfig } from "../db";
|
||||||
|
|
||||||
export interface SchedulerState {
|
export interface SchedulerState {
|
||||||
job_sleep_seconds: number;
|
job_sleep_seconds: number;
|
||||||
schedule_enabled: boolean;
|
schedule_enabled: boolean;
|
||||||
schedule_start: string; // "HH:MM"
|
schedule_start: string; // "HH:MM"
|
||||||
schedule_end: string; // "HH:MM"
|
schedule_end: string; // "HH:MM"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSchedulerState(): SchedulerState {
|
export function getSchedulerState(): SchedulerState {
|
||||||
return {
|
return {
|
||||||
job_sleep_seconds: parseInt(getConfig('job_sleep_seconds') ?? '0', 10),
|
job_sleep_seconds: parseInt(getConfig("job_sleep_seconds") ?? "0", 10),
|
||||||
schedule_enabled: getConfig('schedule_enabled') === '1',
|
schedule_enabled: getConfig("schedule_enabled") === "1",
|
||||||
schedule_start: getConfig('schedule_start') ?? '01:00',
|
schedule_start: getConfig("schedule_start") ?? "01:00",
|
||||||
schedule_end: getConfig('schedule_end') ?? '07:00',
|
schedule_end: getConfig("schedule_end") ?? "07:00",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateSchedulerState(updates: Partial<SchedulerState>): void {
|
export function updateSchedulerState(updates: Partial<SchedulerState>): void {
|
||||||
if (updates.job_sleep_seconds != null) setConfig('job_sleep_seconds', String(updates.job_sleep_seconds));
|
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_enabled != null) setConfig("schedule_enabled", updates.schedule_enabled ? "1" : "0");
|
||||||
if (updates.schedule_start != null) setConfig('schedule_start', updates.schedule_start);
|
if (updates.schedule_start != null) setConfig("schedule_start", updates.schedule_start);
|
||||||
if (updates.schedule_end != null) setConfig('schedule_end', updates.schedule_end);
|
if (updates.schedule_end != null) setConfig("schedule_end", updates.schedule_end);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if current time is within the schedule window. */
|
/** Check if current time is within the schedule window. */
|
||||||
@@ -63,7 +63,7 @@ export function nextWindowTime(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseTime(hhmm: string): number {
|
function parseTime(hhmm: string): number {
|
||||||
const [h, m] = hhmm.split(':').map(Number);
|
const [h, m] = hhmm.split(":").map(Number);
|
||||||
return h * 60 + m;
|
return h * 60 + m;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,12 +71,12 @@ function parseTime(hhmm: string): number {
|
|||||||
export function sleepBetweenJobs(): Promise<void> {
|
export function sleepBetweenJobs(): Promise<void> {
|
||||||
const seconds = getSchedulerState().job_sleep_seconds;
|
const seconds = getSchedulerState().job_sleep_seconds;
|
||||||
if (seconds <= 0) return Promise.resolve();
|
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. */
|
/** Wait until the schedule window opens. Resolves immediately if already in window. */
|
||||||
export function waitForWindow(): Promise<void> {
|
export function waitForWindow(): Promise<void> {
|
||||||
if (isInScheduleWindow()) return Promise.resolve();
|
if (isInScheduleWindow()) return Promise.resolve();
|
||||||
const ms = msUntilWindow();
|
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 {
|
export interface SonarrConfig {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -6,7 +6,7 @@ export interface SonarrConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function headers(apiKey: string): Record<string, string> {
|
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 }> {
|
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. */
|
/** Returns ISO 639-2 original language for a series or null. */
|
||||||
export async function getOriginalLanguage(
|
export async function getOriginalLanguage(cfg: SonarrConfig, tvdbId: string): Promise<string | null> {
|
||||||
cfg: SonarrConfig,
|
|
||||||
tvdbId: string
|
|
||||||
): Promise<string | null> {
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${cfg.url}/api/v3/series?tvdbId=${tvdbId}`, {
|
const res = await fetch(`${cfg.url}/api/v3/series?tvdbId=${tvdbId}`, {
|
||||||
headers: headers(cfg.apiKey),
|
headers: headers(cfg.apiKey),
|
||||||
@@ -47,36 +44,36 @@ export async function getOriginalLanguage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const NAME_TO_639_2: Record<string, string> = {
|
const NAME_TO_639_2: Record<string, string> = {
|
||||||
english: 'eng',
|
english: "eng",
|
||||||
french: 'fra',
|
french: "fra",
|
||||||
german: 'deu',
|
german: "deu",
|
||||||
spanish: 'spa',
|
spanish: "spa",
|
||||||
italian: 'ita',
|
italian: "ita",
|
||||||
portuguese: 'por',
|
portuguese: "por",
|
||||||
japanese: 'jpn',
|
japanese: "jpn",
|
||||||
korean: 'kor',
|
korean: "kor",
|
||||||
chinese: 'zho',
|
chinese: "zho",
|
||||||
arabic: 'ara',
|
arabic: "ara",
|
||||||
russian: 'rus',
|
russian: "rus",
|
||||||
dutch: 'nld',
|
dutch: "nld",
|
||||||
swedish: 'swe',
|
swedish: "swe",
|
||||||
norwegian: 'nor',
|
norwegian: "nor",
|
||||||
danish: 'dan',
|
danish: "dan",
|
||||||
finnish: 'fin',
|
finnish: "fin",
|
||||||
polish: 'pol',
|
polish: "pol",
|
||||||
turkish: 'tur',
|
turkish: "tur",
|
||||||
thai: 'tha',
|
thai: "tha",
|
||||||
hindi: 'hin',
|
hindi: "hin",
|
||||||
hungarian: 'hun',
|
hungarian: "hun",
|
||||||
czech: 'ces',
|
czech: "ces",
|
||||||
romanian: 'ron',
|
romanian: "ron",
|
||||||
greek: 'ell',
|
greek: "ell",
|
||||||
hebrew: 'heb',
|
hebrew: "heb",
|
||||||
persian: 'fas',
|
persian: "fas",
|
||||||
ukrainian: 'ukr',
|
ukrainian: "ukr",
|
||||||
indonesian: 'ind',
|
indonesian: "ind",
|
||||||
malay: 'msa',
|
malay: "msa",
|
||||||
vietnamese: 'vie',
|
vietnamese: "vie",
|
||||||
};
|
};
|
||||||
|
|
||||||
function languageNameToCode(name: string): string | null {
|
function languageNameToCode(name: string): string | null {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
export interface MediaItem {
|
export interface MediaItem {
|
||||||
id: number;
|
id: number;
|
||||||
jellyfin_id: string;
|
jellyfin_id: string;
|
||||||
type: 'Movie' | 'Episode';
|
type: "Movie" | "Episode";
|
||||||
name: string;
|
name: string;
|
||||||
series_name: string | null;
|
series_name: string | null;
|
||||||
series_jellyfin_id: string | null;
|
series_jellyfin_id: string | null;
|
||||||
@@ -14,12 +14,12 @@ export interface MediaItem {
|
|||||||
file_size: number | null;
|
file_size: number | null;
|
||||||
container: string | null;
|
container: string | null;
|
||||||
original_language: 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;
|
needs_review: number;
|
||||||
imdb_id: string | null;
|
imdb_id: string | null;
|
||||||
tmdb_id: string | null;
|
tmdb_id: string | null;
|
||||||
tvdb_id: string | null;
|
tvdb_id: string | null;
|
||||||
scan_status: 'pending' | 'scanned' | 'error';
|
scan_status: "pending" | "scanned" | "error";
|
||||||
scan_error: string | null;
|
scan_error: string | null;
|
||||||
last_scanned_at: string | null;
|
last_scanned_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -29,7 +29,7 @@ export interface MediaStream {
|
|||||||
id: number;
|
id: number;
|
||||||
item_id: number;
|
item_id: number;
|
||||||
stream_index: number;
|
stream_index: number;
|
||||||
type: 'Video' | 'Audio' | 'Subtitle' | 'Data' | 'EmbeddedImage';
|
type: "Video" | "Audio" | "Subtitle" | "Data" | "EmbeddedImage";
|
||||||
codec: string | null;
|
codec: string | null;
|
||||||
language: string | null;
|
language: string | null;
|
||||||
language_display: string | null;
|
language_display: string | null;
|
||||||
@@ -46,11 +46,11 @@ export interface MediaStream {
|
|||||||
export interface ReviewPlan {
|
export interface ReviewPlan {
|
||||||
id: number;
|
id: number;
|
||||||
item_id: number;
|
item_id: number;
|
||||||
status: 'pending' | 'approved' | 'skipped' | 'done' | 'error';
|
status: "pending" | "approved" | "skipped" | "done" | "error";
|
||||||
is_noop: number;
|
is_noop: number;
|
||||||
confidence: 'high' | 'low';
|
confidence: "high" | "low";
|
||||||
apple_compat: 'direct_play' | 'remux' | 'audio_transcode' | null;
|
apple_compat: "direct_play" | "remux" | "audio_transcode" | null;
|
||||||
job_type: 'copy' | 'transcode';
|
job_type: "copy" | "transcode";
|
||||||
subs_extracted: number;
|
subs_extracted: number;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
reviewed_at: string | null;
|
reviewed_at: string | null;
|
||||||
@@ -73,7 +73,7 @@ export interface StreamDecision {
|
|||||||
id: number;
|
id: number;
|
||||||
plan_id: number;
|
plan_id: number;
|
||||||
stream_id: number;
|
stream_id: number;
|
||||||
action: 'keep' | 'remove';
|
action: "keep" | "remove";
|
||||||
target_index: number | null;
|
target_index: number | null;
|
||||||
custom_title: string | null;
|
custom_title: string | null;
|
||||||
transcode_codec: string | null;
|
transcode_codec: string | null;
|
||||||
@@ -83,8 +83,8 @@ export interface Job {
|
|||||||
id: number;
|
id: number;
|
||||||
item_id: number;
|
item_id: number;
|
||||||
command: string;
|
command: string;
|
||||||
job_type: 'copy' | 'transcode';
|
job_type: "copy" | "transcode";
|
||||||
status: 'pending' | 'running' | 'done' | 'error';
|
status: "pending" | "running" | "done" | "error";
|
||||||
output: string | null;
|
output: string | null;
|
||||||
exit_code: number | null;
|
exit_code: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -95,17 +95,22 @@ export interface Job {
|
|||||||
// ─── Analyzer types ───────────────────────────────────────────────────────────
|
// ─── Analyzer types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface StreamWithDecision extends MediaStream {
|
export interface StreamWithDecision extends MediaStream {
|
||||||
action: 'keep' | 'remove';
|
action: "keep" | "remove";
|
||||||
target_index: number | null;
|
target_index: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlanResult {
|
export interface PlanResult {
|
||||||
is_noop: boolean;
|
is_noop: boolean;
|
||||||
has_subs: boolean;
|
has_subs: boolean;
|
||||||
confidence: 'high' | 'low';
|
confidence: "high" | "low";
|
||||||
apple_compat: 'direct_play' | 'remux' | 'audio_transcode' | null;
|
apple_compat: "direct_play" | "remux" | "audio_transcode" | null;
|
||||||
job_type: 'copy' | 'transcode';
|
job_type: "copy" | "transcode";
|
||||||
decisions: Array<{ stream_id: number; action: 'keep' | 'remove'; target_index: number | null; transcode_codec: string | null }>;
|
decisions: Array<{
|
||||||
|
stream_id: number;
|
||||||
|
action: "keep" | "remove";
|
||||||
|
target_index: number | null;
|
||||||
|
transcode_codec: string | null;
|
||||||
|
}>;
|
||||||
notes: string[];
|
notes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +166,7 @@ export interface ScanProgress {
|
|||||||
|
|
||||||
// ─── SSE event helpers ────────────────────────────────────────────────────────
|
// ─── SSE event helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type SseEventType = 'progress' | 'log' | 'complete' | 'error';
|
export type SseEventType = "progress" | "log" | "complete" | "error";
|
||||||
|
|
||||||
export interface SseEvent {
|
export interface SseEvent {
|
||||||
type: SseEventType;
|
type: SseEventType;
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { Link, useNavigate } from "@tanstack/react-router";
|
||||||
import { Link, useNavigate } from '@tanstack/react-router';
|
import { useEffect, useState } from "react";
|
||||||
import { api } from '~/shared/lib/api';
|
import { Alert } from "~/shared/components/ui/alert";
|
||||||
import { Button } from '~/shared/components/ui/button';
|
import { Button } from "~/shared/components/ui/button";
|
||||||
import { Alert } from '~/shared/components/ui/alert';
|
import { api } from "~/shared/lib/api";
|
||||||
|
|
||||||
interface Stats {
|
interface Stats {
|
||||||
totalItems: number; scanned: number; needsAction: number;
|
totalItems: number;
|
||||||
approved: number; done: number; errors: number; noChange: 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 }) {
|
function StatCard({ label, value, danger }: { label: string; value: number; danger?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="border border-gray-200 rounded-lg px-3.5 py-2.5">
|
<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()}
|
{value.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[0.7rem] text-gray-500 mt-0.5">{label}</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);
|
const [starting, setStarting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<DashboardData>('/api/dashboard').then((d) => {
|
api
|
||||||
setData(d);
|
.get<DashboardData>("/api/dashboard")
|
||||||
setLoading(false);
|
.then((d) => {
|
||||||
if (!d.setupComplete) navigate({ to: '/setup' });
|
setData(d);
|
||||||
}).catch(() => setLoading(false));
|
setLoading(false);
|
||||||
|
if (!d.setupComplete) navigate({ to: "/setup" });
|
||||||
|
})
|
||||||
|
.catch(() => setLoading(false));
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
const startScan = async () => {
|
const startScan = async () => {
|
||||||
setStarting(true);
|
setStarting(true);
|
||||||
await api.post('/api/scan/start', {}).catch(() => {});
|
await api.post("/api/scan/start", {}).catch(() => {});
|
||||||
navigate({ to: '/scan' });
|
navigate({ to: "/scan" });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
if (loading) return <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
||||||
@@ -65,18 +77,27 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-3 mb-8">
|
<div className="flex items-center gap-3 mb-8">
|
||||||
{scanRunning ? (
|
{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…
|
⏳ Scan running…
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={startScan} disabled={starting}>
|
<Button onClick={startScan} disabled={starting}>
|
||||||
{starting ? 'Starting…' : '▶ Start Scan'}
|
{starting ? "Starting…" : "▶ Start Scan"}
|
||||||
</Button>
|
</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
|
Review changes
|
||||||
</Link>
|
</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
|
Execute jobs
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,39 +1,46 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { api } from '~/shared/lib/api';
|
import { Badge } from "~/shared/components/ui/badge";
|
||||||
import { Badge } from '~/shared/components/ui/badge';
|
import { Button } from "~/shared/components/ui/button";
|
||||||
import { Button } from '~/shared/components/ui/button';
|
import { FilterTabs } from "~/shared/components/ui/filter-tabs";
|
||||||
import { FilterTabs } from '~/shared/components/ui/filter-tabs';
|
import { api } from "~/shared/lib/api";
|
||||||
import type { Job, MediaItem } from '~/shared/lib/types';
|
import type { Job, MediaItem } from "~/shared/lib/types";
|
||||||
|
|
||||||
interface JobEntry { job: Job; item: MediaItem | null; }
|
interface JobEntry {
|
||||||
interface ExecuteData { jobs: JobEntry[]; filter: string; totalCounts: Record<string, number>; }
|
job: Job;
|
||||||
|
item: MediaItem | null;
|
||||||
|
}
|
||||||
|
interface ExecuteData {
|
||||||
|
jobs: JobEntry[];
|
||||||
|
filter: string;
|
||||||
|
totalCounts: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
const FILTER_TABS = [
|
const FILTER_TABS = [
|
||||||
{ key: 'all', label: 'All' },
|
{ key: "all", label: "All" },
|
||||||
{ key: 'pending', label: 'Pending' },
|
{ key: "pending", label: "Pending" },
|
||||||
{ key: 'running', label: 'Running' },
|
{ key: "running", label: "Running" },
|
||||||
{ key: 'done', label: 'Done' },
|
{ key: "done", label: "Done" },
|
||||||
{ key: 'error', label: 'Error' },
|
{ key: "error", label: "Error" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function itemName(job: Job, item: MediaItem | null): string {
|
function itemName(job: Job, item: MediaItem | null): string {
|
||||||
if (!item) return `Item #${job.item_id}`;
|
if (!item) return `Item #${job.item_id}`;
|
||||||
if (item.type === 'Episode' && item.series_name) {
|
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.series_name} S${String(item.season_number ?? 0).padStart(2, "0")}E${String(item.episode_number ?? 0).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
return item.name;
|
return item.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
function jobTypeLabel(job: Job): string {
|
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
|
// Module-level cache for instant tab switching
|
||||||
const cache = new Map<string, ExecuteData>();
|
const cache = new Map<string, ExecuteData>();
|
||||||
|
|
||||||
export function ExecutePage() {
|
export function ExecutePage() {
|
||||||
const { filter } = useSearch({ from: '/execute' });
|
const { filter } = useSearch({ from: "/execute" });
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [data, setData] = useState<ExecuteData | null>(cache.get(filter) ?? null);
|
const [data, setData] = useState<ExecuteData | null>(cache.get(filter) ?? null);
|
||||||
const [loading, setLoading] = useState(!cache.has(filter));
|
const [loading, setLoading] = useState(!cache.has(filter));
|
||||||
@@ -46,22 +53,35 @@ export function ExecutePage() {
|
|||||||
const load = (f?: string) => {
|
const load = (f?: string) => {
|
||||||
const key = f ?? filter;
|
const key = f ?? filter;
|
||||||
const cached = cache.get(key);
|
const cached = cache.get(key);
|
||||||
if (cached && key === filter) { setData(cached); setLoading(false); }
|
if (cached && key === filter) {
|
||||||
else if (key === filter) { setLoading(true); }
|
setData(cached);
|
||||||
api.get<ExecuteData>(`/api/execute?filter=${key}`)
|
setLoading(false);
|
||||||
.then((d) => { cache.set(key, d); if (key === filter) { setData(d); setLoading(false); } })
|
} else if (key === filter) {
|
||||||
.catch(() => { if (key === filter) setLoading(false); });
|
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(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, [filter]);
|
}, [load]);
|
||||||
|
|
||||||
// SSE for live job updates
|
// SSE for live job updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const es = new EventSource('/api/execute/events');
|
const es = new EventSource("/api/execute/events");
|
||||||
esRef.current = es;
|
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 };
|
const d = JSON.parse(e.data) as { id: number; status: string; output?: string };
|
||||||
|
|
||||||
// Update job in current list if present
|
// Update job in current list if present
|
||||||
@@ -71,7 +91,7 @@ export function ExecutePage() {
|
|||||||
if (jobIdx === -1) return prev;
|
if (jobIdx === -1) return prev;
|
||||||
|
|
||||||
const oldStatus = prev.jobs[jobIdx].job.status;
|
const oldStatus = prev.jobs[jobIdx].job.status;
|
||||||
const newStatus = d.status as Job['status'];
|
const newStatus = d.status as Job["status"];
|
||||||
|
|
||||||
// Live-update totalCounts
|
// Live-update totalCounts
|
||||||
const newCounts = { ...prev.totalCounts };
|
const newCounts = { ...prev.totalCounts };
|
||||||
@@ -84,18 +104,20 @@ export function ExecutePage() {
|
|||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
totalCounts: newCounts,
|
totalCounts: newCounts,
|
||||||
jobs: prev.jobs.map((j) =>
|
jobs: prev.jobs.map((j) => (j.job.id === d.id ? { ...j, job: { ...j.job, status: newStatus } } : j)),
|
||||||
j.job.id === d.id ? { ...j, job: { ...j.job, status: newStatus } } : j
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (d.output !== undefined) {
|
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
|
// 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);
|
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
|
||||||
reloadTimerRef.current = setTimeout(() => {
|
reloadTimerRef.current = setTimeout(() => {
|
||||||
// Invalidate cache and reload current filter
|
// Invalidate cache and reload current filter
|
||||||
@@ -104,17 +126,50 @@ export function ExecutePage() {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => { es.close(); if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current); };
|
return () => {
|
||||||
}, [filter]);
|
es.close();
|
||||||
|
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
const startAll = async () => { await api.post('/api/execute/start'); cache.clear(); load(); };
|
const startAll = async () => {
|
||||||
const clearQueue = async () => { await api.post('/api/execute/clear'); cache.clear(); load(); };
|
await api.post("/api/execute/start");
|
||||||
const clearCompleted = async () => { await api.post('/api/execute/clear-completed'); cache.clear(); load(); };
|
cache.clear();
|
||||||
const runJob = async (id: number) => { await api.post(`/api/execute/job/${id}/run`); cache.clear(); load(); };
|
load();
|
||||||
const cancelJob = async (id: number) => { await api.post(`/api/execute/job/${id}/cancel`); 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 toggleLog = (id: number) =>
|
||||||
const toggleCmd = (id: number) => setCmdVisible((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
|
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 totalCounts = data?.totalCounts ?? { all: 0, pending: 0, running: 0, done: 0, error: 0 };
|
||||||
const pending = totalCounts.pending ?? 0;
|
const pending = totalCounts.pending ?? 0;
|
||||||
@@ -130,27 +185,31 @@ export function ExecutePage() {
|
|||||||
<h1 className="text-xl font-bold mb-4">Execute Jobs</h1>
|
<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">
|
<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 && (
|
{totalCounts.all === 0 && !loading && <span className="text-sm text-gray-500">No jobs yet.</span>}
|
||||||
<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-400">Loading...</span>
|
|
||||||
)}
|
|
||||||
{allDone && (
|
|
||||||
<span className="text-sm font-medium">All jobs completed</span>
|
|
||||||
)}
|
|
||||||
{running > 0 && (
|
{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 && (
|
{pending > 0 && (
|
||||||
<>
|
<>
|
||||||
<span className="text-sm font-medium">{pending} job{pending !== 1 ? 's' : ''} pending</span>
|
<span className="text-sm font-medium">
|
||||||
<Button size="sm" onClick={startAll}>Run all pending</Button>
|
{pending} job{pending !== 1 ? "s" : ""} pending
|
||||||
<Button size="sm" variant="secondary" onClick={clearQueue}>Clear queue</Button>
|
</span>
|
||||||
|
<Button size="sm" onClick={startAll}>
|
||||||
|
Run all pending
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" onClick={clearQueue}>
|
||||||
|
Clear queue
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{(done > 0 || errors > 0) && (
|
{(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>
|
</div>
|
||||||
|
|
||||||
@@ -158,83 +217,110 @@ export function ExecutePage() {
|
|||||||
tabs={FILTER_TABS}
|
tabs={FILTER_TABS}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
totalCounts={totalCounts}
|
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>}
|
{loading && !data && <div className="text-gray-400 py-8 text-center">Loading…</div>}
|
||||||
|
|
||||||
{jobs.length > 0 && (
|
{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">
|
||||||
<thead>
|
<table className="w-full border-collapse text-[0.82rem]">
|
||||||
<tr>
|
<thead>
|
||||||
{['#', 'Item', 'Type', 'Status', 'Actions'].map((h) => (
|
<tr>
|
||||||
<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
|
||||||
</tr>
|
key={h}
|
||||||
</thead>
|
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"
|
||||||
<tbody>
|
>
|
||||||
{jobs.map(({ job, item }: JobEntry) => {
|
{h}
|
||||||
const name = itemName(job, item);
|
</th>
|
||||||
const jobLog = logs.get(job.id) ?? job.output ?? '';
|
))}
|
||||||
const showLog = logVisible.has(job.id) || job.status === 'running' || job.status === 'error';
|
</tr>
|
||||||
const showCmd = cmdVisible.has(job.id);
|
</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 showCmd = cmdVisible.has(job.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr key={job.id} className="hover:bg-gray-50">
|
<tr key={job.id} className="hover:bg-gray-50">
|
||||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{job.id}</td>
|
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{job.id}</td>
|
||||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||||
<div className="truncate max-w-[300px]" title={name}>
|
<div className="truncate max-w-[300px]" title={name}>
|
||||||
{item ? (
|
{item ? (
|
||||||
<Link to="/review/audio/$id" params={{ id: String(item.id) }} className="text-inherit no-underline hover:underline">{name}</Link>
|
<Link
|
||||||
) : name}
|
to="/review/audio/$id"
|
||||||
</div>
|
params={{ id: String(item.id) }}
|
||||||
</td>
|
className="text-inherit no-underline hover:underline"
|
||||||
<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>
|
{name}
|
||||||
</td>
|
</Link>
|
||||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
) : (
|
||||||
<Badge variant={job.status}>{job.status}</Badge>
|
name
|
||||||
{job.exit_code != null && job.exit_code !== 0 && <Badge variant="error" className="ml-1">exit {job.exit_code}</Badge>}
|
)}
|
||||||
</td>
|
</div>
|
||||||
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap">
|
</td>
|
||||||
<div className="flex gap-1 items-center">
|
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap">
|
||||||
{job.status === 'pending' && (
|
<Badge variant={job.job_type === "subtitle" ? "noop" : "default"}>{jobTypeLabel(job)}</Badge>
|
||||||
<>
|
</td>
|
||||||
<Button size="sm" onClick={() => runJob(job.id)}>▶ Run</Button>
|
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||||
<Button size="sm" variant="secondary" onClick={() => cancelJob(job.id)}>✕</Button>
|
<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>
|
||||||
)}
|
)}
|
||||||
<Button size="sm" variant="secondary" onClick={() => toggleCmd(job.id)}>Cmd</Button>
|
</td>
|
||||||
{(job.status === 'done' || job.status === 'error') && jobLog && (
|
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap">
|
||||||
<Button size="sm" variant="secondary" onClick={() => toggleLog(job.id)}>Log</Button>
|
<div className="flex gap-1 items-center">
|
||||||
)}
|
{job.status === "pending" && (
|
||||||
</div>
|
<>
|
||||||
</td>
|
<Button size="sm" onClick={() => runJob(job.id)}>
|
||||||
</tr>
|
▶ Run
|
||||||
{showCmd && (
|
</Button>
|
||||||
<tr key={`cmd-${job.id}`}>
|
<Button size="sm" variant="secondary" onClick={() => cancelJob(job.id)}>
|
||||||
<td colSpan={5} className="p-0 border-b border-gray-100">
|
✕
|
||||||
<div className="font-mono text-[0.74rem] bg-gray-50 text-gray-700 px-3.5 py-2.5 rounded max-h-[120px] overflow-y-auto whitespace-pre-wrap break-all">
|
</Button>
|
||||||
{job.command}
|
</>
|
||||||
|
)}
|
||||||
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
{showCmd && (
|
||||||
{jobLog && showLog && (
|
<tr key={`cmd-${job.id}`}>
|
||||||
<tr key={`log-${job.id}`}>
|
<td colSpan={5} className="p-0 border-b border-gray-100">
|
||||||
<td colSpan={5} className="p-0 border-b border-gray-100">
|
<div className="font-mono text-[0.74rem] bg-gray-50 text-gray-700 px-3.5 py-2.5 rounded max-h-[120px] overflow-y-auto whitespace-pre-wrap break-all">
|
||||||
<div className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#d4d4d4] px-3.5 py-2.5 rounded max-h-[260px] overflow-y-auto whitespace-pre-wrap break-all">
|
{job.command}
|
||||||
{jobLog}
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
)}
|
||||||
)}
|
{jobLog && showLog && (
|
||||||
</>
|
<tr key={`log-${job.id}`}>
|
||||||
);
|
<td colSpan={5} className="p-0 border-b border-gray-100">
|
||||||
})}
|
<div className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#d4d4d4] px-3.5 py-2.5 rounded max-h-[260px] overflow-y-auto whitespace-pre-wrap break-all">
|
||||||
</tbody>
|
{jobLog}
|
||||||
</table></div>
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && jobs.length === 0 && totalCounts.all > 0 && (
|
{!loading && jobs.length === 0 && totalCounts.all > 0 && (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import { api } from '~/shared/lib/api';
|
import { Badge } from "~/shared/components/ui/badge";
|
||||||
import { Badge } from '~/shared/components/ui/badge';
|
import { Button } from "~/shared/components/ui/button";
|
||||||
import { Button } from '~/shared/components/ui/button';
|
import { api } from "~/shared/lib/api";
|
||||||
|
|
||||||
interface PathInfo {
|
interface PathInfo {
|
||||||
prefix: string;
|
prefix: string;
|
||||||
@@ -17,12 +17,18 @@ export function PathsPage() {
|
|||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api.get<{ paths: PathInfo[] }>('/api/paths')
|
api
|
||||||
.then((d) => { cache = d.paths; setPaths(d.paths); })
|
.get<{ paths: PathInfo[] }>("/api/paths")
|
||||||
|
.then((d) => {
|
||||||
|
cache = d.paths;
|
||||||
|
setPaths(d.paths);
|
||||||
|
})
|
||||||
.finally(() => setLoading(false));
|
.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 allGood = paths.length > 0 && paths.every((p) => p.accessible);
|
||||||
const hasBroken = paths.some((p) => !p.accessible);
|
const hasBroken = paths.some((p) => !p.accessible);
|
||||||
@@ -35,17 +41,16 @@ export function PathsPage() {
|
|||||||
{paths.length === 0 && !loading && (
|
{paths.length === 0 && !loading && (
|
||||||
<span className="text-sm text-gray-500">No media items scanned yet. Run a scan first.</span>
|
<span className="text-sm text-gray-500">No media items scanned yet. Run a scan first.</span>
|
||||||
)}
|
)}
|
||||||
{paths.length === 0 && loading && (
|
{paths.length === 0 && loading && <span className="text-sm text-gray-400">Checking paths...</span>}
|
||||||
<span className="text-sm text-gray-400">Checking paths...</span>
|
{allGood && <span className="text-sm font-medium">All {paths.length} paths accessible</span>}
|
||||||
)}
|
|
||||||
{allGood && (
|
|
||||||
<span className="text-sm font-medium">All {paths.length} paths accessible</span>
|
|
||||||
)}
|
|
||||||
{hasBroken && (
|
{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}>
|
<Button variant="secondary" size="sm" onClick={load} disabled={loading}>
|
||||||
{loading ? 'Checking...' : 'Refresh'}
|
{loading ? "Checking..." : "Refresh"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 font-mono text-sm">{p.prefix}</td>
|
||||||
<td className="py-2 pr-4 text-right tabular-nums">{p.itemCount}</td>
|
<td className="py-2 pr-4 text-right tabular-nums">{p.itemCount}</td>
|
||||||
<td className="py-2">
|
<td className="py-2">
|
||||||
{p.accessible ? (
|
{p.accessible ? <Badge variant="keep">Accessible</Badge> : <Badge variant="error">Not mounted</Badge>}
|
||||||
<Badge variant="keep">Accessible</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="error">Not mounted</Badge>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -78,8 +79,8 @@ export function PathsPage() {
|
|||||||
|
|
||||||
{paths.some((p) => !p.accessible) && (
|
{paths.some((p) => !p.accessible) && (
|
||||||
<p className="mt-4 text-xs text-gray-500">
|
<p className="mt-4 text-xs text-gray-500">
|
||||||
Paths marked "Not mounted" are not reachable from the container.
|
Paths marked "Not mounted" are not reachable from the container. Mount each path into the Docker container
|
||||||
Mount each path into the Docker container exactly as Jellyfin reports it.
|
exactly as Jellyfin reports it.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Badge } from '~/shared/components/ui/badge';
|
import { Badge } from "~/shared/components/ui/badge";
|
||||||
|
|
||||||
interface DoneColumnProps {
|
interface DoneColumnProps {
|
||||||
items: any[];
|
items: any[];
|
||||||
@@ -14,14 +14,10 @@ export function DoneColumn({ items }: DoneColumnProps) {
|
|||||||
{items.map((item: any) => (
|
{items.map((item: any) => (
|
||||||
<div key={item.id} className="rounded border bg-white p-2">
|
<div key={item.id} className="rounded border bg-white p-2">
|
||||||
<p className="text-xs font-medium truncate">{item.name}</p>
|
<p className="text-xs font-medium truncate">{item.name}</p>
|
||||||
<Badge variant={item.status === 'done' ? 'done' : 'error'}>
|
<Badge variant={item.status === "done" ? "done" : "error"}>{item.status}</Badge>
|
||||||
{item.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{items.length === 0 && (
|
{items.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No completed items</p>}
|
||||||
<p className="text-sm text-gray-400 text-center py-8">No completed items</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Badge } from '~/shared/components/ui/badge';
|
import { Badge } from "~/shared/components/ui/badge";
|
||||||
import { LANG_NAMES, langName } from '~/shared/lib/lang';
|
import { LANG_NAMES, langName } from "~/shared/lib/lang";
|
||||||
|
|
||||||
interface PipelineCardProps {
|
interface PipelineCardProps {
|
||||||
item: any;
|
item: any;
|
||||||
@@ -9,15 +9,15 @@ interface PipelineCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApproveUpTo }: PipelineCardProps) {
|
export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApproveUpTo }: PipelineCardProps) {
|
||||||
const title = item.type === 'Episode'
|
const title =
|
||||||
? `S${String(item.season_number).padStart(2, '0')}E${String(item.episode_number).padStart(2, '0')} — ${item.name}`
|
item.type === "Episode"
|
||||||
: item.name;
|
? `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
|
const jellyfinLink =
|
||||||
? `${jellyfinUrl}/web/index.html#!/details?id=${item.jellyfin_id}`
|
jellyfinUrl && item.jellyfin_id ? `${jellyfinUrl}/web/index.html#!/details?id=${item.jellyfin_id}` : null;
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`group rounded-lg border p-3 ${confidenceColor}`}>
|
<div className={`group rounded-lg border p-3 ${confidenceColor}`}>
|
||||||
@@ -40,12 +40,14 @@ export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApproveUpT
|
|||||||
{onLanguageChange ? (
|
{onLanguageChange ? (
|
||||||
<select
|
<select
|
||||||
className="h-6 text-xs border border-gray-300 rounded px-1 bg-white"
|
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)}
|
onChange={(e) => onLanguageChange(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">unknown</option>
|
<option value="">unknown</option>
|
||||||
{Object.entries(LANG_NAMES).map(([code, name]) => (
|
{Object.entries(LANG_NAMES).map(([code, name]) => (
|
||||||
<option key={code} value={code}>{name}</option>
|
<option key={code} value={code}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
@@ -54,12 +56,11 @@ export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApproveUpT
|
|||||||
|
|
||||||
{item.transcode_reasons?.length > 0
|
{item.transcode_reasons?.length > 0
|
||||||
? item.transcode_reasons.map((r: string) => (
|
? item.transcode_reasons.map((r: string) => (
|
||||||
<Badge key={r} variant="manual">{r}</Badge>
|
<Badge key={r} variant="manual">
|
||||||
))
|
{r}
|
||||||
: item.job_type === 'copy' && (
|
</Badge>
|
||||||
<Badge variant="noop">copy</Badge>
|
))
|
||||||
)
|
: item.job_type === "copy" && <Badge variant="noop">copy</Badge>}
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { api } from '~/shared/lib/api';
|
import { api } from "~/shared/lib/api";
|
||||||
import { ReviewColumn } from './ReviewColumn';
|
import { DoneColumn } from "./DoneColumn";
|
||||||
import { QueueColumn } from './QueueColumn';
|
import { ProcessingColumn } from "./ProcessingColumn";
|
||||||
import { ProcessingColumn } from './ProcessingColumn';
|
import { QueueColumn } from "./QueueColumn";
|
||||||
import { DoneColumn } from './DoneColumn';
|
import { ReviewColumn } from "./ReviewColumn";
|
||||||
import { ScheduleControls } from './ScheduleControls';
|
import { ScheduleControls } from "./ScheduleControls";
|
||||||
|
|
||||||
interface PipelineData {
|
interface PipelineData {
|
||||||
review: any[];
|
review: any[];
|
||||||
@@ -43,24 +43,26 @@ export function PipelinePage() {
|
|||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const [pipelineRes, schedulerRes] = await Promise.all([
|
const [pipelineRes, schedulerRes] = await Promise.all([
|
||||||
api.get<PipelineData>('/api/review/pipeline'),
|
api.get<PipelineData>("/api/review/pipeline"),
|
||||||
api.get<SchedulerState>('/api/execute/scheduler'),
|
api.get<SchedulerState>("/api/execute/scheduler"),
|
||||||
]);
|
]);
|
||||||
setData(pipelineRes);
|
setData(pipelineRes);
|
||||||
setScheduler(schedulerRes);
|
setScheduler(schedulerRes);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
// SSE for live updates
|
// SSE for live updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const es = new EventSource('/api/execute/events');
|
const es = new EventSource("/api/execute/events");
|
||||||
es.addEventListener('job_update', () => load());
|
es.addEventListener("job_update", () => load());
|
||||||
es.addEventListener('job_progress', (e) => {
|
es.addEventListener("job_progress", (e) => {
|
||||||
setProgress(JSON.parse((e as MessageEvent).data));
|
setProgress(JSON.parse((e as MessageEvent).data));
|
||||||
});
|
});
|
||||||
es.addEventListener('queue_status', (e) => {
|
es.addEventListener("queue_status", (e) => {
|
||||||
setQueueStatus(JSON.parse((e as MessageEvent).data));
|
setQueueStatus(JSON.parse((e as MessageEvent).data));
|
||||||
});
|
});
|
||||||
return () => es.close();
|
return () => es.close();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Badge } from '~/shared/components/ui/badge';
|
import { Badge } from "~/shared/components/ui/badge";
|
||||||
|
|
||||||
interface ProcessingColumnProps {
|
interface ProcessingColumnProps {
|
||||||
items: any[];
|
items: any[];
|
||||||
@@ -12,18 +12,18 @@ export function ProcessingColumn({ items, progress, queueStatus }: ProcessingCol
|
|||||||
const formatTime = (s: number) => {
|
const formatTime = (s: number) => {
|
||||||
const m = Math.floor(s / 60);
|
const m = Math.floor(s / 60);
|
||||||
const sec = 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 (
|
return (
|
||||||
<div className="flex flex-col w-72 min-w-72 min-h-0 bg-gray-50 rounded-lg">
|
<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="px-3 py-2 border-b font-medium text-sm">Processing</div>
|
||||||
<div className="flex-1 p-3">
|
<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">
|
<div className="mb-3 text-xs text-gray-500 bg-white rounded border p-2">
|
||||||
{queueStatus.status === 'paused' && <>Paused until {queueStatus.until}</>}
|
{queueStatus.status === "paused" && <>Paused until {queueStatus.until}</>}
|
||||||
{queueStatus.status === 'sleeping' && <>Sleeping {queueStatus.seconds}s between jobs</>}
|
{queueStatus.status === "sleeping" && <>Sleeping {queueStatus.seconds}s between jobs</>}
|
||||||
{queueStatus.status === 'idle' && <>Idle</>}
|
{queueStatus.status === "idle" && <>Idle</>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -32,9 +32,7 @@ export function ProcessingColumn({ items, progress, queueStatus }: ProcessingCol
|
|||||||
<p className="text-sm font-medium truncate">{job.name}</p>
|
<p className="text-sm font-medium truncate">{job.name}</p>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<Badge variant="running">running</Badge>
|
<Badge variant="running">running</Badge>
|
||||||
<Badge variant={job.job_type === 'transcode' ? 'manual' : 'noop'}>
|
<Badge variant={job.job_type === "transcode" ? "manual" : "noop"}>{job.job_type}</Badge>
|
||||||
{job.job_type}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{progress && progress.total > 0 && (
|
{progress && progress.total > 0 && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Badge } from '~/shared/components/ui/badge';
|
import { Badge } from "~/shared/components/ui/badge";
|
||||||
|
|
||||||
interface QueueColumnProps {
|
interface QueueColumnProps {
|
||||||
items: any[];
|
items: any[];
|
||||||
@@ -14,14 +14,10 @@ export function QueueColumn({ items }: QueueColumnProps) {
|
|||||||
{items.map((item: any) => (
|
{items.map((item: any) => (
|
||||||
<div key={item.id} className="rounded border bg-white p-2">
|
<div key={item.id} className="rounded border bg-white p-2">
|
||||||
<p className="text-xs font-medium truncate">{item.name}</p>
|
<p className="text-xs font-medium truncate">{item.name}</p>
|
||||||
<Badge variant={item.job_type === 'transcode' ? 'manual' : 'noop'}>
|
<Badge variant={item.job_type === "transcode" ? "manual" : "noop"}>{item.job_type}</Badge>
|
||||||
{item.job_type}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{items.length === 0 && (
|
{items.length === 0 && <p className="text-sm text-gray-400 text-center py-8">Queue empty</p>}
|
||||||
<p className="text-sm text-gray-400 text-center py-8">Queue empty</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { api } from '~/shared/lib/api';
|
import { api } from "~/shared/lib/api";
|
||||||
import { PipelineCard } from './PipelineCard';
|
import { PipelineCard } from "./PipelineCard";
|
||||||
import { SeriesCard } from './SeriesCard';
|
import { SeriesCard } from "./SeriesCard";
|
||||||
|
|
||||||
interface ReviewColumnProps {
|
interface ReviewColumnProps {
|
||||||
items: any[];
|
items: any[];
|
||||||
@@ -10,10 +10,10 @@ interface ReviewColumnProps {
|
|||||||
|
|
||||||
export function ReviewColumn({ items, jellyfinUrl, onMutate }: ReviewColumnProps) {
|
export function ReviewColumn({ items, jellyfinUrl, onMutate }: ReviewColumnProps) {
|
||||||
// Group by series (movies are standalone)
|
// 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[] }>();
|
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;
|
const key = item.series_jellyfin_id ?? item.series_name;
|
||||||
if (!seriesMap.has(key)) {
|
if (!seriesMap.has(key)) {
|
||||||
seriesMap.set(key, { name: item.series_name, key, jellyfinId: item.series_jellyfin_id, episodes: [] });
|
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)
|
// Interleave movies and series, sorted by confidence (high first)
|
||||||
const allItems = [
|
const allItems = [
|
||||||
...movies.map((m: any) => ({ type: 'movie' as const, item: m, sortKey: m.confidence === 'high' ? 0 : 1 })),
|
...movies.map((m: any) => ({ type: "movie" as const, item: m, sortKey: m.confidence === "high" ? 0 : 1 })),
|
||||||
...[...seriesMap.values()].map(s => ({
|
...[...seriesMap.values()].map((s) => ({
|
||||||
type: 'series' as const,
|
type: "series" as const,
|
||||||
item: s,
|
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);
|
].sort((a, b) => a.sortKey - b.sortKey);
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export function ReviewColumn({ items, jellyfinUrl, onMutate }: ReviewColumnProps
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
||||||
{allItems.map((entry) => {
|
{allItems.map((entry) => {
|
||||||
if (entry.type === 'movie') {
|
if (entry.type === "movie") {
|
||||||
return (
|
return (
|
||||||
<PipelineCard
|
<PipelineCard
|
||||||
key={entry.item.id}
|
key={entry.item.id}
|
||||||
@@ -77,9 +77,7 @@ export function ReviewColumn({ items, jellyfinUrl, onMutate }: ReviewColumnProps
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
{allItems.length === 0 && (
|
{allItems.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No items to review</p>}
|
||||||
<p className="text-sm text-gray-400 text-center py-8">No items to review</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { api } from '~/shared/lib/api';
|
import { Button } from "~/shared/components/ui/button";
|
||||||
import { Input } from '~/shared/components/ui/input';
|
import { Input } from "~/shared/components/ui/input";
|
||||||
import { Button } from '~/shared/components/ui/button';
|
import { api } from "~/shared/lib/api";
|
||||||
|
|
||||||
interface ScheduleControlsProps {
|
interface ScheduleControlsProps {
|
||||||
scheduler: {
|
scheduler: {
|
||||||
@@ -18,13 +18,13 @@ export function ScheduleControls({ scheduler, onUpdate }: ScheduleControlsProps)
|
|||||||
const [state, setState] = useState(scheduler);
|
const [state, setState] = useState(scheduler);
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
await api.patch('/api/execute/scheduler', state);
|
await api.patch("/api/execute/scheduler", state);
|
||||||
onUpdate();
|
onUpdate();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const startAll = async () => {
|
const startAll = async () => {
|
||||||
await api.post('/api/execute/start');
|
await api.post("/api/execute/start");
|
||||||
onUpdate();
|
onUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,10 +33,7 @@ export function ScheduleControls({ scheduler, onUpdate }: ScheduleControlsProps)
|
|||||||
<Button variant="primary" size="sm" onClick={startAll}>
|
<Button variant="primary" size="sm" onClick={startAll}>
|
||||||
Start queue
|
Start queue
|
||||||
</Button>
|
</Button>
|
||||||
<button
|
<button onClick={() => setOpen(!open)} className="text-xs text-gray-500 hover:text-gray-700 cursor-pointer">
|
||||||
onClick={() => setOpen(!open)}
|
|
||||||
className="text-xs text-gray-500 hover:text-gray-700 cursor-pointer"
|
|
||||||
>
|
|
||||||
Schedule settings
|
Schedule settings
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -49,7 +46,7 @@ export function ScheduleControls({ scheduler, onUpdate }: ScheduleControlsProps)
|
|||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
value={state.job_sleep_seconds}
|
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"
|
className="mb-3"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -80,7 +77,9 @@ export function ScheduleControls({ scheduler, onUpdate }: ScheduleControlsProps)
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button variant="primary" size="sm" onClick={save}>Save</Button>
|
<Button variant="primary" size="sm" onClick={save}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { api } from '~/shared/lib/api';
|
import { api } from "~/shared/lib/api";
|
||||||
import { LANG_NAMES } from '~/shared/lib/lang';
|
import { LANG_NAMES } from "~/shared/lib/lang";
|
||||||
import { PipelineCard } from './PipelineCard';
|
import { PipelineCard } from "./PipelineCard";
|
||||||
|
|
||||||
interface SeriesCardProps {
|
interface SeriesCardProps {
|
||||||
seriesKey: string;
|
seriesKey: string;
|
||||||
@@ -13,10 +13,18 @@ interface SeriesCardProps {
|
|||||||
onApproveUpTo?: () => void;
|
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 [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
const seriesLang = episodes[0]?.original_language ?? '';
|
const seriesLang = episodes[0]?.original_language ?? "";
|
||||||
|
|
||||||
const setSeriesLanguage = async (lang: string) => {
|
const setSeriesLanguage = async (lang: string) => {
|
||||||
await api.patch(`/api/review/series/${encodeURIComponent(seriesKey)}/language`, { language: lang });
|
await api.patch(`/api/review/series/${encodeURIComponent(seriesKey)}/language`, { language: lang });
|
||||||
@@ -28,12 +36,11 @@ export function SeriesCard({ seriesKey, seriesName, jellyfinUrl, seriesJellyfinI
|
|||||||
onMutate();
|
onMutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
const highCount = episodes.filter((e: any) => e.confidence === 'high').length;
|
const highCount = episodes.filter((e: any) => e.confidence === "high").length;
|
||||||
const lowCount = episodes.filter((e: any) => e.confidence === 'low').length;
|
const lowCount = episodes.filter((e: any) => e.confidence === "low").length;
|
||||||
|
|
||||||
const jellyfinLink = jellyfinUrl && seriesJellyfinId
|
const jellyfinLink =
|
||||||
? `${jellyfinUrl}/web/index.html#!/details?id=${seriesJellyfinId}`
|
jellyfinUrl && seriesJellyfinId ? `${jellyfinUrl}/web/index.html#!/details?id=${seriesJellyfinId}` : null;
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group rounded-lg border bg-white overflow-hidden">
|
<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"
|
className="flex items-center gap-2 px-3 pt-3 pb-1 cursor-pointer hover:bg-gray-50 rounded-t-lg"
|
||||||
onClick={() => setExpanded(!expanded)}
|
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 ? (
|
{jellyfinLink ? (
|
||||||
<a
|
<a
|
||||||
href={jellyfinLink}
|
href={jellyfinLink}
|
||||||
@@ -67,15 +74,23 @@ export function SeriesCard({ seriesKey, seriesName, jellyfinUrl, seriesJellyfinI
|
|||||||
<select
|
<select
|
||||||
className="h-6 text-xs border border-gray-300 rounded px-1 bg-white shrink-0"
|
className="h-6 text-xs border border-gray-300 rounded px-1 bg-white shrink-0"
|
||||||
value={seriesLang}
|
value={seriesLang}
|
||||||
onChange={(e) => { e.stopPropagation(); setSeriesLanguage(e.target.value); }}
|
onChange={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSeriesLanguage(e.target.value);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<option value="">unknown</option>
|
<option value="">unknown</option>
|
||||||
{Object.entries(LANG_NAMES).map(([code, name]) => (
|
{Object.entries(LANG_NAMES).map(([code, name]) => (
|
||||||
<option key={code} value={code}>{name}</option>
|
<option key={code} value={code}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button
|
<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"
|
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
|
Approve all
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { Link, useParams } from "@tanstack/react-router";
|
||||||
import { Link, useParams } from '@tanstack/react-router';
|
import { useEffect, useState } from "react";
|
||||||
import { api } from '~/shared/lib/api';
|
import { Alert } from "~/shared/components/ui/alert";
|
||||||
import { Badge } from '~/shared/components/ui/badge';
|
import { Badge } from "~/shared/components/ui/badge";
|
||||||
import { Button } from '~/shared/components/ui/button';
|
import { Button } from "~/shared/components/ui/button";
|
||||||
import { Alert } from '~/shared/components/ui/alert';
|
import { Select } from "~/shared/components/ui/select";
|
||||||
import { Select } from '~/shared/components/ui/select';
|
import { api } from "~/shared/lib/api";
|
||||||
import { langName, LANG_NAMES } from '~/shared/lib/lang';
|
import { LANG_NAMES, langName } from "~/shared/lib/lang";
|
||||||
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '~/shared/lib/types';
|
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from "~/shared/lib/types";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface DetailData {
|
interface DetailData {
|
||||||
item: MediaItem; streams: MediaStream[];
|
item: MediaItem;
|
||||||
plan: ReviewPlan | null; decisions: StreamDecision[];
|
streams: MediaStream[];
|
||||||
|
plan: ReviewPlan | null;
|
||||||
|
decisions: StreamDecision[];
|
||||||
command: string | null;
|
command: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,15 +30,15 @@ function formatBytes(bytes: number): string {
|
|||||||
function effectiveLabel(s: MediaStream, dec: StreamDecision | undefined): string {
|
function effectiveLabel(s: MediaStream, dec: StreamDecision | undefined): string {
|
||||||
if (dec?.custom_title) return dec.custom_title;
|
if (dec?.custom_title) return dec.custom_title;
|
||||||
if (s.title) return s.title;
|
if (s.title) return s.title;
|
||||||
if (s.type === 'Audio' && s.channels) return `${s.channels}ch ${s.channel_layout ?? ''}`.trim();
|
if (s.type === "Audio" && s.channels) return `${s.channels}ch ${s.channel_layout ?? ""}`.trim();
|
||||||
return s.language ? langName(s.language) : '';
|
return s.language ? langName(s.language) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Stream table ─────────────────────────────────────────────────────────────
|
// ─── Stream table ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const STREAM_SECTIONS = [
|
const STREAM_SECTIONS = [
|
||||||
{ type: 'Video', label: 'Video' },
|
{ type: "Video", label: "Video" },
|
||||||
{ type: 'Audio', label: 'Audio' },
|
{ type: "Audio", label: "Audio" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const TYPE_ORDER: Record<string, number> = { Video: 0, Audio: 1, Subtitle: 2, Data: 3, EmbeddedImage: 4 };
|
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). */
|
/** Compute per-type output indices for kept streams (e.g. a:0, a:1). */
|
||||||
function computeOutIdx(streams: MediaStream[], decisions: StreamDecision[]): Map<number, string> {
|
function computeOutIdx(streams: MediaStream[], decisions: StreamDecision[]): Map<number, string> {
|
||||||
const mappedKept = streams
|
const mappedKept = streams
|
||||||
.filter((s) => ['Video', 'Audio'].includes(s.type))
|
.filter((s) => ["Video", "Audio"].includes(s.type))
|
||||||
.filter((s) => {
|
.filter((s) => {
|
||||||
const action = decisions.find((d) => d.stream_id === s.id)?.action;
|
const action = decisions.find((d) => d.stream_id === s.id)?.action;
|
||||||
return action === 'keep';
|
return action === "keep";
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const ta = TYPE_ORDER[a.type] ?? 9;
|
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 m = new Map<number, string>();
|
||||||
const typeCounts: Record<string, number> = {};
|
const typeCounts: Record<string, number> = {};
|
||||||
for (const s of mappedKept) {
|
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;
|
const idx = typeCounts[s.type] ?? 0;
|
||||||
m.set(s.id, `${prefix}:${idx}`);
|
m.set(s.id, `${prefix}:${idx}`);
|
||||||
typeCounts[s.type] = idx + 1;
|
typeCounts[s.type] = idx + 1;
|
||||||
@@ -68,14 +70,19 @@ function computeOutIdx(streams: MediaStream[], decisions: StreamDecision[]): Map
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StreamTableProps { data: DetailData; onUpdate: (d: DetailData) => void; }
|
interface StreamTableProps {
|
||||||
|
data: DetailData;
|
||||||
|
onUpdate: (d: DetailData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
function StreamTable({ data, onUpdate }: StreamTableProps) {
|
function StreamTable({ data, onUpdate }: StreamTableProps) {
|
||||||
const { item, streams, plan, decisions } = data;
|
const { item, streams, plan, decisions } = data;
|
||||||
const outIdx = computeOutIdx(streams, decisions);
|
const outIdx = computeOutIdx(streams, decisions);
|
||||||
|
|
||||||
const toggleStream = async (streamId: number, currentAction: 'keep' | 'remove') => {
|
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 d = await api.patch<DetailData>(`/api/review/${item.id}/stream/${streamId}`, {
|
||||||
|
action: currentAction === "keep" ? "remove" : "keep",
|
||||||
|
});
|
||||||
onUpdate(d);
|
onUpdate(d);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,102 +92,113 @@ function StreamTable({ data, onUpdate }: StreamTableProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
||||||
<thead>
|
<table className="w-full border-collapse text-[0.79rem] mt-1">
|
||||||
<tr>
|
<thead>
|
||||||
{['Out', 'Codec', 'Language', 'Title / Info', 'Flags', 'Action'].map((h) => (
|
<tr>
|
||||||
<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
|
||||||
</tr>
|
key={h}
|
||||||
</thead>
|
className="text-left text-[0.67rem] uppercase tracking-[0.05em] text-gray-500 py-1 px-2 border-b border-gray-200"
|
||||||
<tbody>
|
>
|
||||||
{STREAM_SECTIONS.flatMap(({ type, label }) => {
|
{h}
|
||||||
const group = streams.filter((s) => s.type === type);
|
</th>
|
||||||
if (group.length === 0) return [];
|
))}
|
||||||
return [
|
</tr>
|
||||||
<tr key={`hdr-${type}`}>
|
</thead>
|
||||||
<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">
|
<tbody>
|
||||||
{label}
|
{STREAM_SECTIONS.flatMap(({ type, label }) => {
|
||||||
</td>
|
const group = streams.filter((s) => s.type === type);
|
||||||
</tr>,
|
if (group.length === 0) return [];
|
||||||
...group.map((s) => {
|
return [
|
||||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
<tr key={`hdr-${type}`}>
|
||||||
const action = dec?.action ?? 'keep';
|
<td
|
||||||
const isAudio = s.type === 'Audio';
|
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 outputNum = outIdx.get(s.id);
|
const outputNum = outIdx.get(s.id);
|
||||||
const lbl = effectiveLabel(s, dec);
|
const lbl = effectiveLabel(s, dec);
|
||||||
const origTitle = s.title;
|
const origTitle = s.title;
|
||||||
const lang = langName(s.language);
|
const lang = langName(s.language);
|
||||||
const isEditable = plan?.status === 'pending' && isAudio;
|
const isEditable = plan?.status === "pending" && isAudio;
|
||||||
const rowBg = action === 'keep' ? 'bg-green-50' : 'bg-red-50';
|
const rowBg = action === "keep" ? "bg-green-50" : "bg-red-50";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={s.id} className={rowBg}>
|
<tr key={s.id} className={rowBg}>
|
||||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">
|
<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>}
|
{outputNum !== undefined ? outputNum : <span className="text-gray-400">—</span>}
|
||||||
</td>
|
</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">
|
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||||
{isAudio ? (
|
{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>
|
<span className="text-gray-400">—</span>
|
||||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
)}
|
||||||
{isEditable ? (
|
</td>
|
||||||
<TitleInput
|
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||||
value={lbl}
|
{isEditable ? <TitleInput value={lbl} onCommit={(v) => updateTitle(s.id, v)} /> : <span>{lbl || "—"}</span>}
|
||||||
onCommit={(v) => updateTitle(s.id, v)}
|
{isEditable && origTitle && origTitle !== lbl && (
|
||||||
/>
|
<div className="text-gray-400 text-[0.7rem] mt-0.5">orig: {origTitle}</div>
|
||||||
) : (
|
)}
|
||||||
<span>{lbl || '—'}</span>
|
</td>
|
||||||
)}
|
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||||
{isEditable && origTitle && origTitle !== lbl && (
|
<span className="inline-flex gap-1">
|
||||||
<div className="text-gray-400 text-[0.7rem] mt-0.5">orig: {origTitle}</div>
|
{s.is_default ? <Badge>default</Badge> : null}
|
||||||
)}
|
{s.is_forced ? <Badge variant="manual">forced</Badge> : null}
|
||||||
</td>
|
{s.is_hearing_impaired ? <Badge>CC</Badge> : null}
|
||||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
</span>
|
||||||
<span className="inline-flex gap-1">
|
</td>
|
||||||
{s.is_default ? <Badge>default</Badge> : null}
|
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||||
{s.is_forced ? <Badge variant="manual">forced</Badge> : null}
|
{plan?.status === "pending" && isAudio ? (
|
||||||
{s.is_hearing_impaired ? <Badge>CC</Badge> : null}
|
<button
|
||||||
</span>
|
type="button"
|
||||||
</td>
|
onClick={() => toggleStream(s.id, action)}
|
||||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
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"}`}
|
||||||
{plan?.status === 'pending' && isAudio ? (
|
>
|
||||||
<button
|
{action === "keep" ? "✓ Keep" : "✗ Remove"}
|
||||||
type="button"
|
</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'}`}
|
<Badge variant={action === "keep" ? "keep" : "remove"}>{action}</Badge>
|
||||||
>
|
)}
|
||||||
{action === 'keep' ? '✓ Keep' : '✗ Remove'}
|
</td>
|
||||||
</button>
|
</tr>
|
||||||
) : (
|
);
|
||||||
<Badge variant={action === 'keep' ? 'keep' : 'remove'}>{action}</Badge>
|
}),
|
||||||
)}
|
];
|
||||||
</td>
|
})}
|
||||||
</tr>
|
</tbody>
|
||||||
);
|
</table>
|
||||||
}),
|
</div>
|
||||||
];
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table></div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
|
function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
|
||||||
const [localVal, setLocalVal] = useState(value);
|
const [localVal, setLocalVal] = useState(value);
|
||||||
useEffect(() => { setLocalVal(value); }, [value]);
|
useEffect(() => {
|
||||||
|
setLocalVal(value);
|
||||||
|
}, [value]);
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={localVal}
|
value={localVal}
|
||||||
onChange={(e) => setLocalVal(e.target.value)}
|
onChange={(e) => setLocalVal(e.target.value)}
|
||||||
onBlur={(e) => { if (e.target.value !== value) onCommit(e.target.value); }}
|
onBlur={(e) => {
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
|
if (e.target.value !== value) onCommit(e.target.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||||
|
}}
|
||||||
placeholder="—"
|
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"
|
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 ──────────────────────────────────────────────────────────────
|
// ─── Detail page ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function AudioDetailPage() {
|
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 [data, setData] = useState<DetailData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [rescanning, setRescanning] = useState(false);
|
const [rescanning, setRescanning] = useState(false);
|
||||||
|
|
||||||
const load = () => api.get<DetailData>(`/api/review/${id}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
|
const load = () =>
|
||||||
useEffect(() => { load(); }, [id]);
|
api
|
||||||
|
.get<DetailData>(`/api/review/${id}`)
|
||||||
|
.then((d) => {
|
||||||
|
setData(d);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => setLoading(false));
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
const setLanguage = async (lang: string) => {
|
const setLanguage = async (lang: string) => {
|
||||||
const d = await api.patch<DetailData>(`/api/review/${id}/language`, { language: lang || null });
|
const d = await api.patch<DetailData>(`/api/review/${id}/language`, { language: lang || null });
|
||||||
setData(d);
|
setData(d);
|
||||||
};
|
};
|
||||||
|
|
||||||
const approve = async () => { await api.post(`/api/review/${id}/approve`); load(); };
|
const approve = async () => {
|
||||||
const unapprove = async () => { await api.post(`/api/review/${id}/unapprove`); load(); };
|
await api.post(`/api/review/${id}/approve`);
|
||||||
const skip = async () => { await api.post(`/api/review/${id}/skip`); load(); };
|
load();
|
||||||
const unskip = async () => { await api.post(`/api/review/${id}/unskip`); 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 () => {
|
const rescan = async () => {
|
||||||
setRescanning(true);
|
setRescanning(true);
|
||||||
try { const d = await api.post<DetailData>(`/api/review/${id}/rescan`); setData(d); }
|
try {
|
||||||
finally { setRescanning(false); }
|
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 (loading) return <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
||||||
if (!data) return <Alert variant="error">Item not found.</Alert>;
|
if (!data) return <Alert variant="error">Item not found.</Alert>;
|
||||||
|
|
||||||
const { item, plan, command } = data;
|
const { item, plan, command } = data;
|
||||||
const statusKey = plan?.is_noop ? 'noop' : (plan?.status ?? 'pending');
|
const statusKey = plan?.is_noop ? "noop" : (plan?.status ?? "pending");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<h1 className="text-xl font-bold m-0">
|
<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}
|
{item.name}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,12 +277,17 @@ export function AudioDetailPage() {
|
|||||||
{/* Meta */}
|
{/* Meta */}
|
||||||
<dl className="flex flex-wrap gap-5 mb-3 text-[0.82rem]">
|
<dl className="flex flex-wrap gap-5 mb-3 text-[0.82rem]">
|
||||||
{[
|
{[
|
||||||
{ label: 'Type', value: item.type },
|
{ label: "Type", value: item.type },
|
||||||
...(item.series_name ? [{ label: 'Series', value: item.series_name }] : []),
|
...(item.series_name ? [{ label: "Series", value: item.series_name }] : []),
|
||||||
...(item.year ? [{ label: 'Year', value: String(item.year) }] : []),
|
...(item.year ? [{ label: "Year", value: String(item.year) }] : []),
|
||||||
{ label: 'Container', value: item.container ?? '—' },
|
{ label: "Container", value: item.container ?? "—" },
|
||||||
{ label: 'File size', value: item.file_size ? formatBytes(item.file_size) : '—' },
|
{ 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: "Status",
|
||||||
|
value: (
|
||||||
|
<Badge variant={statusKey as "noop" | "pending" | "approved" | "skipped" | "done" | "error"}>{statusKey}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
].map((entry, i) => (
|
].map((entry, i) => (
|
||||||
<div key={i}>
|
<div key={i}>
|
||||||
<dt className="text-gray-500 text-[0.68rem] uppercase tracking-[0.05em] mb-0.5">{entry.label}</dt>
|
<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>
|
<div className="font-mono text-gray-400 text-[0.78rem] mb-4 break-all">{item.file_path}</div>
|
||||||
|
|
||||||
{/* Warnings */}
|
{/* 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 && (
|
{item.needs_review && !item.original_language && (
|
||||||
<Alert variant="warning" className="mb-3">
|
<Alert variant="warning" className="mb-3">
|
||||||
Original language unknown — audio tracks will NOT be filtered until you set it below.
|
Original language unknown — audio tracks will NOT be filtered until you set it below.
|
||||||
@@ -259,10 +313,16 @@ export function AudioDetailPage() {
|
|||||||
{/* Language override */}
|
{/* Language override */}
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<label className="text-[0.85rem] m-0">Original language:</label>
|
<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>
|
<option value="">— Unknown —</option>
|
||||||
{Object.entries(LANG_NAMES).map(([code, name]) => (
|
{Object.entries(LANG_NAMES).map(([code, name]) => (
|
||||||
<option key={code} value={code}>{name} ({code})</option>
|
<option key={code} value={code}>
|
||||||
|
{name} ({code})
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
{item.orig_lang_source && <Badge>{item.orig_lang_source}</Badge>}
|
{item.orig_lang_source && <Badge>{item.orig_lang_source}</Badge>}
|
||||||
@@ -285,33 +345,43 @@ export function AudioDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
{plan?.status === 'pending' && !plan.is_noop && (
|
{plan?.status === "pending" && !plan.is_noop && (
|
||||||
<div className="flex gap-2 mt-6">
|
<div className="flex gap-2 mt-6">
|
||||||
<Button onClick={approve}>✓ Approve</Button>
|
<Button onClick={approve}>✓ Approve</Button>
|
||||||
<Button variant="secondary" onClick={skip}>Skip</Button>
|
<Button variant="secondary" onClick={skip}>
|
||||||
|
Skip
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{plan?.status === 'approved' && (
|
{plan?.status === "approved" && (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<Button variant="secondary" onClick={unapprove}>Unapprove</Button>
|
<Button variant="secondary" onClick={unapprove}>
|
||||||
|
Unapprove
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{plan?.status === 'skipped' && (
|
{plan?.status === "skipped" && (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<Button variant="secondary" onClick={unskip}>Unskip</Button>
|
<Button variant="secondary" onClick={unskip}>
|
||||||
|
Unskip
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{plan?.is_noop ? (
|
{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}
|
) : null}
|
||||||
|
|
||||||
{/* Refresh */}
|
{/* Refresh */}
|
||||||
<div className="flex items-center gap-3 mt-6 pt-3 border-t border-gray-200">
|
<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}>
|
<Button variant="secondary" size="sm" onClick={rescan} disabled={rescanning}>
|
||||||
{rescanning ? '↻ Refreshing…' : '↻ Refresh from Jellyfin'}
|
{rescanning ? "↻ Refreshing…" : "↻ Refresh from Jellyfin"}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-gray-400 text-[0.75rem]">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,34 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
|
import { useEffect, useState } from "react";
|
||||||
import { api } from '~/shared/lib/api';
|
import { Badge } from "~/shared/components/ui/badge";
|
||||||
import { Badge } from '~/shared/components/ui/badge';
|
import { Button } from "~/shared/components/ui/button";
|
||||||
import { Button } from '~/shared/components/ui/button';
|
import { FilterTabs } from "~/shared/components/ui/filter-tabs";
|
||||||
import { FilterTabs } from '~/shared/components/ui/filter-tabs';
|
import { api } from "~/shared/lib/api";
|
||||||
import { langName } from '~/shared/lib/lang';
|
import { langName } from "~/shared/lib/lang";
|
||||||
import type { MediaItem, ReviewPlan } from '~/shared/lib/types';
|
import type { MediaItem, ReviewPlan } from "~/shared/lib/types";
|
||||||
|
|
||||||
// ─── 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 {
|
interface SeriesGroup {
|
||||||
series_key: string; series_name: string; original_language: string | null;
|
series_key: string;
|
||||||
season_count: number; episode_count: number;
|
series_name: string;
|
||||||
noop_count: number; needs_action_count: number; approved_count: number;
|
original_language: string | null;
|
||||||
skipped_count: number; done_count: number; error_count: number; manual_count: number;
|
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 {
|
interface ReviewListData {
|
||||||
@@ -28,10 +41,14 @@ interface ReviewListData {
|
|||||||
// ─── Filter tabs ──────────────────────────────────────────────────────────────
|
// ─── Filter tabs ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const FILTER_TABS = [
|
const FILTER_TABS = [
|
||||||
{ key: 'all', label: 'All' }, { key: 'needs_action', label: 'Needs Action' },
|
{ key: "all", label: "All" },
|
||||||
{ key: 'noop', label: 'No Change' }, { key: 'manual', label: 'Manual Review' },
|
{ key: "needs_action", label: "Needs Action" },
|
||||||
{ key: 'approved', label: 'Approved' }, { key: 'skipped', label: 'Skipped' },
|
{ key: "noop", label: "No Change" },
|
||||||
{ key: 'done', label: 'Done' }, { key: 'error', label: 'Error' },
|
{ key: "manual", label: "Manual Review" },
|
||||||
|
{ key: "approved", label: "Approved" },
|
||||||
|
{ key: "skipped", label: "Skipped" },
|
||||||
|
{ key: "done", label: "Done" },
|
||||||
|
{ key: "error", label: "Error" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Status pills ─────────────────────────────────────────────────────────────
|
// ─── Status pills ─────────────────────────────────────────────────────────────
|
||||||
@@ -39,13 +56,41 @@ const FILTER_TABS = [
|
|||||||
function StatusPills({ g }: { g: SeriesGroup }) {
|
function StatusPills({ g }: { g: SeriesGroup }) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex flex-wrap gap-1 items-center">
|
<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.noop_count > 0 && (
|
||||||
{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>}
|
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">
|
||||||
{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.noop_count} ok
|
||||||
{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>}
|
</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.needs_action_count > 0 && (
|
||||||
{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 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>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -59,7 +104,7 @@ const Th = ({ children }: { children: React.ReactNode }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const Td = ({ children, className }: { children?: React.ReactNode; className?: string }) => (
|
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) ─────────────────────────────────────────────────
|
// ─── Series row (collapsible) ─────────────────────────────────────────────────
|
||||||
@@ -68,8 +113,19 @@ function SeriesRow({ g }: { g: SeriesGroup }) {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const urlKey = encodeURIComponent(g.series_key);
|
const urlKey = encodeURIComponent(g.series_key);
|
||||||
|
|
||||||
interface EpisodeItem { item: MediaItem; plan: ReviewPlan | null; removeCount: number; }
|
interface EpisodeItem {
|
||||||
interface SeasonGroup { season: number | null; episodes: EpisodeItem[]; noopCount: number; actionCount: number; approvedCount: number; doneCount: number; }
|
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);
|
const [seasons, setSeasons] = useState<SeasonGroup[] | null>(null);
|
||||||
|
|
||||||
@@ -93,25 +149,30 @@ function SeriesRow({ g }: { g: SeriesGroup }) {
|
|||||||
window.location.reload();
|
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 (
|
return (
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr className="cursor-pointer hover:bg-gray-50" onClick={toggle}>
|
||||||
className="cursor-pointer hover:bg-gray-50"
|
|
||||||
onClick={toggle}
|
|
||||||
>
|
|
||||||
<td className="py-1.5 px-2 border-b border-gray-100 font-medium">
|
<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>
|
<span
|
||||||
{' '}<strong>{g.series_name}</strong>
|
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>
|
||||||
<Td>{langName(g.original_language)}</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.season_count}</td>
|
||||||
<td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.episode_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()}>
|
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
|
||||||
{g.needs_action_count > 0 && (
|
{g.needs_action_count > 0 && (
|
||||||
<Button size="xs" onClick={approveAll}>Approve all</Button>
|
<Button size="xs" onClick={approveAll}>
|
||||||
|
Approve all
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -123,13 +184,32 @@ function SeriesRow({ g }: { g: SeriesGroup }) {
|
|||||||
{seasons.map((s) => (
|
{seasons.map((s) => (
|
||||||
<>
|
<>
|
||||||
<tr key={`season-${s.season}`} className="bg-gray-50">
|
<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">
|
<td
|
||||||
Season {s.season ?? '?'}
|
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">
|
<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.noopCount > 0 && (
|
||||||
{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>}
|
<span className="px-1.5 py-0.5 rounded-full bg-gray-200 text-gray-600 text-[0.7rem]">
|
||||||
{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.noopCount} ok
|
||||||
{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 && (
|
||||||
|
<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>
|
</span>
|
||||||
{s.actionCount > 0 && (
|
{s.actionCount > 0 && (
|
||||||
<Button size="xs" variant="secondary" className="ml-3" onClick={(e) => approveSeason(e, s.season)}>
|
<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 }) => (
|
{s.episodes.map(({ item, plan, removeCount }) => (
|
||||||
<tr key={item.id} className="hover:bg-gray-50">
|
<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">
|
<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>
|
<span className="truncate inline-block max-w-xs align-bottom">{item.name}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem]">
|
<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>
|
||||||
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem]">
|
<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>
|
||||||
<td className="py-1 px-2 border-b border-gray-100 whitespace-nowrap flex gap-1 items-center">
|
<td className="py-1 px-2 border-b border-gray-100 whitespace-nowrap flex gap-1 items-center">
|
||||||
{plan?.status === 'pending' && !plan.is_noop && (
|
{plan?.status === "pending" && !plan.is_noop && <ApproveBtn itemId={item.id} size="xs" />}
|
||||||
<ApproveBtn itemId={item.id} size="xs" />
|
{plan?.status === "pending" && <SkipBtn itemId={item.id} size="xs" />}
|
||||||
)}
|
{plan?.status === "skipped" && <UnskipBtn itemId={item.id} size="xs" />}
|
||||||
{plan?.status === 'pending' && (
|
<Link
|
||||||
<SkipBtn itemId={item.id} size="xs" />
|
to="/review/audio/$id"
|
||||||
)}
|
params={{ id: String(item.id) }}
|
||||||
{plan?.status === 'skipped' && (
|
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"
|
||||||
<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
|
Detail
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
@@ -180,19 +265,40 @@ function SeriesRow({ g }: { g: SeriesGroup }) {
|
|||||||
|
|
||||||
// ─── Action buttons ───────────────────────────────────────────────────────────
|
// ─── Action buttons ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ApproveBtn({ itemId, size }: { itemId: number; size?: 'xs' | 'sm' }) {
|
function ApproveBtn({ itemId, size }: { itemId: number; size?: "xs" | "sm" }) {
|
||||||
const onClick = async () => { await api.post(`/api/review/${itemId}/approve`); window.location.reload(); };
|
const onClick = async () => {
|
||||||
return <Button size={size ?? 'xs'} onClick={onClick}>Approve</Button>;
|
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' }) {
|
function SkipBtn({ itemId, size }: { itemId: number; size?: "xs" | "sm" }) {
|
||||||
const onClick = async () => { await api.post(`/api/review/${itemId}/skip`); window.location.reload(); };
|
const onClick = async () => {
|
||||||
return <Button size={size ?? 'xs'} variant="secondary" onClick={onClick}>Skip</Button>;
|
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' }) {
|
function UnskipBtn({ itemId, size }: { itemId: number; size?: "xs" | "sm" }) {
|
||||||
const onClick = async () => { await api.post(`/api/review/${itemId}/unskip`); window.location.reload(); };
|
const onClick = async () => {
|
||||||
return <Button size={size ?? 'xs'} variant="secondary" onClick={onClick}>Unskip</Button>;
|
await api.post(`/api/review/${itemId}/unskip`);
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Button size={size ?? "xs"} variant="secondary" onClick={onClick}>
|
||||||
|
Unskip
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Cache ────────────────────────────────────────────────────────────────────
|
// ─── Cache ────────────────────────────────────────────────────────────────────
|
||||||
@@ -202,22 +308,31 @@ const cache = new Map<string, ReviewListData>();
|
|||||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function AudioListPage() {
|
export function AudioListPage() {
|
||||||
const { filter } = useSearch({ from: '/review/audio/' });
|
const { filter } = useSearch({ from: "/review/audio/" });
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [data, setData] = useState<ReviewListData | null>(cache.get(filter) ?? null);
|
const [data, setData] = useState<ReviewListData | null>(cache.get(filter) ?? null);
|
||||||
const [loading, setLoading] = useState(!cache.has(filter));
|
const [loading, setLoading] = useState(!cache.has(filter));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cached = cache.get(filter);
|
const cached = cache.get(filter);
|
||||||
if (cached) { setData(cached); setLoading(false); }
|
if (cached) {
|
||||||
else { setLoading(true); }
|
setData(cached);
|
||||||
api.get<ReviewListData>(`/api/review?filter=${filter}`)
|
setLoading(false);
|
||||||
.then((d) => { cache.set(filter, d); setData(d); 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));
|
.catch(() => setLoading(false));
|
||||||
}, [filter]);
|
}, [filter]);
|
||||||
|
|
||||||
const approveAll = async () => {
|
const approveAll = async () => {
|
||||||
await api.post('/api/review/approve-all');
|
await api.post("/api/review/approve-all");
|
||||||
cache.clear();
|
cache.clear();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
@@ -227,7 +342,7 @@ export function AudioListPage() {
|
|||||||
|
|
||||||
const { movies, series, totalCounts } = data;
|
const { movies, series, totalCounts } = data;
|
||||||
const hasPending = (totalCounts.needs_action ?? 0) > 0;
|
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 (
|
return (
|
||||||
<div>
|
<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">
|
<div className="border border-gray-200 rounded-lg px-4 py-3 mb-6 flex items-center gap-3 flex-wrap">
|
||||||
{hasPending ? (
|
{hasPending ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-sm font-medium">{totalCounts.needs_action} item{totalCounts.needs_action !== 1 ? 's' : ''} need{totalCounts.needs_action === 1 ? 's' : ''} review</span>
|
<span className="text-sm font-medium">
|
||||||
<Button size="sm" onClick={approveAll}>Approve all pending</Button>
|
{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>
|
<span className="text-sm font-medium">All items reviewed</span>
|
||||||
@@ -248,12 +368,10 @@ export function AudioListPage() {
|
|||||||
tabs={FILTER_TABS}
|
tabs={FILTER_TABS}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
totalCounts={totalCounts}
|
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 && (
|
{movies.length === 0 && series.length === 0 && <p className="text-gray-500">No items match this filter.</p>}
|
||||||
<p className="text-gray-500">No items match this filter.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Movies */}
|
{/* Movies */}
|
||||||
{movies.length > 0 && (
|
{movies.length > 0 && (
|
||||||
@@ -262,54 +380,89 @@ export function AudioListPage() {
|
|||||||
Movies <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{movies.length}</span>
|
Movies <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{movies.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-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]">
|
<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>
|
||||||
<tbody>
|
<tr>
|
||||||
{movies.map(({ item, plan, removeCount }) => (
|
<Th>Name</Th>
|
||||||
<tr key={item.id} className="hover:bg-gray-50">
|
<Th>Lang</Th>
|
||||||
<Td>
|
<Th>Remove</Th>
|
||||||
<span className="truncate inline-block max-w-[200px] sm:max-w-[360px]" title={item.name}>{item.name}</span>
|
<Th>Status</Th>
|
||||||
{item.year && <span className="text-gray-400 text-[0.72rem]"> ({item.year})</span>}
|
<Th>Actions</Th>
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
{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 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">
|
|
||||||
Detail
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{movies.map(({ item, plan, removeCount }) => (
|
||||||
</div>
|
<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>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
Detail
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* TV Series */}
|
{/* TV Series */}
|
||||||
{series.length > 0 && (
|
{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>
|
TV Series <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{series.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-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]">
|
<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>
|
<thead>
|
||||||
{series.map((g) => <SeriesRow key={g.series_key} g={g} />)}
|
<tr>
|
||||||
</table>
|
<Th>Series</Th>
|
||||||
</div>
|
<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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 { Link } from '@tanstack/react-router';
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { api } from '~/shared/lib/api';
|
import { Badge } from "~/shared/components/ui/badge";
|
||||||
import { Button } from '~/shared/components/ui/button';
|
import { Button } from "~/shared/components/ui/button";
|
||||||
import { Badge } from '~/shared/components/ui/badge';
|
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 ScanStatus {
|
||||||
interface LogEntry { name: string; type: string; status: string; file?: string; }
|
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
|
// Mutable buffer for SSE data — flushed to React state on an interval
|
||||||
interface SseBuf {
|
interface SseBuf {
|
||||||
@@ -20,18 +30,18 @@ interface SseBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function freshBuf(): 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;
|
const FLUSH_MS = 200;
|
||||||
|
|
||||||
export function ScanPage() {
|
export function ScanPage() {
|
||||||
const [status, setStatus] = useState<ScanStatus | null>(null);
|
const [status, setStatus] = useState<ScanStatus | null>(null);
|
||||||
const [limit, setLimit] = useState('');
|
const [limit, setLimit] = useState("");
|
||||||
const [log, setLog] = useState<LogEntry[]>([]);
|
const [log, setLog] = useState<LogEntry[]>([]);
|
||||||
const [statusLabel, setStatusLabel] = useState('');
|
const [statusLabel, setStatusLabel] = useState("");
|
||||||
const [scanComplete, setScanComplete] = useState(false);
|
const [scanComplete, setScanComplete] = useState(false);
|
||||||
const [currentItem, setCurrentItem] = useState('');
|
const [currentItem, setCurrentItem] = useState("");
|
||||||
const [progressScanned, setProgressScanned] = useState(0);
|
const [progressScanned, setProgressScanned] = useState(0);
|
||||||
const [progressTotal, setProgressTotal] = useState(0);
|
const [progressTotal, setProgressTotal] = useState(0);
|
||||||
const [errors, setErrors] = useState(0);
|
const [errors, setErrors] = useState(0);
|
||||||
@@ -59,19 +69,19 @@ export function ScanPage() {
|
|||||||
if (b.complete) {
|
if (b.complete) {
|
||||||
const d = b.complete;
|
const d = b.complete;
|
||||||
b.complete = null;
|
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);
|
setScanComplete(true);
|
||||||
setStatus((prev) => prev ? { ...prev, running: false } : prev);
|
setStatus((prev) => (prev ? { ...prev, running: false } : prev));
|
||||||
stopFlushing();
|
stopFlushing();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (b.lost) {
|
if (b.lost) {
|
||||||
b.lost = false;
|
b.lost = false;
|
||||||
setStatusLabel('Scan connection lost — refresh to see current status');
|
setStatusLabel("Scan connection lost — refresh to see current status");
|
||||||
setStatus((prev) => prev ? { ...prev, running: false } : prev);
|
setStatus((prev) => (prev ? { ...prev, running: false } : prev));
|
||||||
stopFlushing();
|
stopFlushing();
|
||||||
}
|
}
|
||||||
}, []);
|
}, [stopFlushing]);
|
||||||
|
|
||||||
const startFlushing = useCallback(() => {
|
const startFlushing = useCallback(() => {
|
||||||
if (timerRef.current) return;
|
if (timerRef.current) return;
|
||||||
@@ -86,50 +96,57 @@ export function ScanPage() {
|
|||||||
}, [flush]);
|
}, [flush]);
|
||||||
|
|
||||||
// Cleanup timer on unmount
|
// Cleanup timer on unmount
|
||||||
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current); }, []);
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const s = await api.get<ScanStatus>('/api/scan');
|
const s = await api.get<ScanStatus>("/api/scan");
|
||||||
setStatus(s);
|
setStatus(s);
|
||||||
setProgressScanned(s.progress.scanned);
|
setProgressScanned(s.progress.scanned);
|
||||||
setProgressTotal(s.progress.total);
|
setProgressTotal(s.progress.total);
|
||||||
setErrors(s.progress.errors);
|
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));
|
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 })));
|
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(() => {
|
const connectSse = useCallback(() => {
|
||||||
esRef.current?.close();
|
esRef.current?.close();
|
||||||
const buf = bufRef.current;
|
const buf = bufRef.current;
|
||||||
const es = new EventSource('/api/scan/events');
|
const es = new EventSource("/api/scan/events");
|
||||||
esRef.current = es;
|
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 };
|
const d = JSON.parse(e.data) as { scanned: number; total: number; errors: number; current_item: string };
|
||||||
buf.scanned = d.scanned;
|
buf.scanned = d.scanned;
|
||||||
buf.total = d.total;
|
buf.total = d.total;
|
||||||
buf.errors = d.errors;
|
buf.errors = d.errors;
|
||||||
buf.currentItem = d.current_item ?? '';
|
buf.currentItem = d.current_item ?? "";
|
||||||
buf.dirty = true;
|
buf.dirty = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
es.addEventListener('log', (e) => {
|
es.addEventListener("log", (e) => {
|
||||||
const d = JSON.parse(e.data) as LogEntry;
|
const d = JSON.parse(e.data) as LogEntry;
|
||||||
buf.newLogs.push(d);
|
buf.newLogs.push(d);
|
||||||
buf.dirty = true;
|
buf.dirty = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
es.addEventListener('complete', (e) => {
|
es.addEventListener("complete", (e) => {
|
||||||
const d = JSON.parse(e.data || '{}') as { scanned?: number; errors?: number };
|
const d = JSON.parse(e.data || "{}") as { scanned?: number; errors?: number };
|
||||||
es.close();
|
es.close();
|
||||||
esRef.current = null;
|
esRef.current = null;
|
||||||
buf.complete = d;
|
buf.complete = d;
|
||||||
});
|
});
|
||||||
|
|
||||||
es.addEventListener('error', () => {
|
es.addEventListener("error", () => {
|
||||||
es.close();
|
es.close();
|
||||||
esRef.current = null;
|
esRef.current = null;
|
||||||
buf.lost = true;
|
buf.lost = true;
|
||||||
@@ -143,7 +160,11 @@ export function ScanPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!status?.running || esRef.current) return;
|
if (!status?.running || esRef.current) return;
|
||||||
connectSse();
|
connectSse();
|
||||||
return () => { esRef.current?.close(); esRef.current = null; stopFlushing(); };
|
return () => {
|
||||||
|
esRef.current?.close();
|
||||||
|
esRef.current = null;
|
||||||
|
stopFlushing();
|
||||||
|
};
|
||||||
}, [status?.running, connectSse, stopFlushing]);
|
}, [status?.running, connectSse, stopFlushing]);
|
||||||
|
|
||||||
const startScan = async () => {
|
const startScan = async () => {
|
||||||
@@ -151,26 +172,26 @@ export function ScanPage() {
|
|||||||
setProgressScanned(0);
|
setProgressScanned(0);
|
||||||
setProgressTotal(0);
|
setProgressTotal(0);
|
||||||
setErrors(0);
|
setErrors(0);
|
||||||
setCurrentItem('');
|
setCurrentItem("");
|
||||||
setStatusLabel('Scan in progress…');
|
setStatusLabel("Scan in progress…");
|
||||||
setScanComplete(false);
|
setScanComplete(false);
|
||||||
setStatus((prev) => prev ? { ...prev, running: true } : prev);
|
setStatus((prev) => (prev ? { ...prev, running: true } : prev));
|
||||||
bufRef.current = freshBuf();
|
bufRef.current = freshBuf();
|
||||||
|
|
||||||
// Connect SSE before starting the scan so no events are missed
|
// Connect SSE before starting the scan so no events are missed
|
||||||
connectSse();
|
connectSse();
|
||||||
|
|
||||||
const limitNum = limit ? Number(limit) : undefined;
|
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 () => {
|
const stopScan = async () => {
|
||||||
await api.post('/api/scan/stop', {});
|
await api.post("/api/scan/stop", {});
|
||||||
esRef.current?.close();
|
esRef.current?.close();
|
||||||
esRef.current = null;
|
esRef.current = null;
|
||||||
stopFlushing();
|
stopFlushing();
|
||||||
setStatus((prev) => prev ? { ...prev, running: false } : prev);
|
setStatus((prev) => (prev ? { ...prev, running: false } : prev));
|
||||||
setStatusLabel('Scan stopped');
|
setStatusLabel("Scan stopped");
|
||||||
};
|
};
|
||||||
|
|
||||||
const pct = progressTotal > 0 ? Math.round((progressScanned / progressTotal) * 100) : 0;
|
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="border border-gray-200 rounded-lg px-4 py-3 mb-6">
|
||||||
<div className="flex items-center flex-wrap gap-2 mb-3">
|
<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 && (
|
{scanComplete && (
|
||||||
<Link to="/pipeline" className="text-blue-600 hover:underline text-sm">
|
<Link to="/pipeline" className="text-blue-600 hover:underline text-sm">
|
||||||
Review in Pipeline →
|
Review in Pipeline →
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{running ? (
|
{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">
|
<div className="flex items-center gap-2">
|
||||||
<label className="flex items-center gap-1.5 text-xs m-0">
|
<label className="flex items-center gap-1.5 text-xs m-0">
|
||||||
@@ -204,7 +227,9 @@ export function ScanPage() {
|
|||||||
/>
|
/>
|
||||||
items
|
items
|
||||||
</label>
|
</label>
|
||||||
<Button size="sm" onClick={startScan}>Start Scan</Button>
|
<Button size="sm" onClick={startScan}>
|
||||||
|
Start Scan
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{errors > 0 && <Badge variant="error">{errors} error(s)</Badge>}
|
{errors > 0 && <Badge variant="error">{errors} error(s)</Badge>}
|
||||||
@@ -218,7 +243,10 @@ export function ScanPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 text-gray-500 text-xs">
|
<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>}
|
{currentItem && <span className="truncate max-w-xs text-gray-400">{currentItem}</span>}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -230,20 +258,27 @@ export function ScanPage() {
|
|||||||
<table className="w-full border-collapse text-[0.82rem]">
|
<table className="w-full border-collapse text-[0.82rem]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{['Type', 'File', 'Status'].map((h) => (
|
{["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>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{log.map((item, i) => {
|
{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 (
|
return (
|
||||||
<tr key={i} className="hover:bg-gray-50">
|
<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">{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">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import { api } from '~/shared/lib/api';
|
import { Button } from "~/shared/components/ui/button";
|
||||||
import { Button } from '~/shared/components/ui/button';
|
import { Input } from "~/shared/components/ui/input";
|
||||||
import { Input } from '~/shared/components/ui/input';
|
import { Select } from "~/shared/components/ui/select";
|
||||||
import { Select } from '~/shared/components/ui/select';
|
import { api } from "~/shared/lib/api";
|
||||||
import { LANG_NAMES } from '~/shared/lib/lang';
|
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;
|
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>) {
|
function LockedInput({ locked, ...props }: { locked: boolean } & React.InputHTMLAttributes<HTMLInputElement>) {
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input {...props} disabled={locked || props.disabled} className={locked ? 'pr-9' : ''} />
|
<Input {...props} disabled={locked || props.disabled} className={locked ? "pr-9" : ""} />
|
||||||
{locked && (
|
{locked && (
|
||||||
<span
|
<span
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[0.9rem] opacity-40 pointer-events-none select-none"
|
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 (
|
return (
|
||||||
<span
|
<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"
|
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={
|
||||||
? `Set via environment variable ${envVar} — edit your .env file to change`
|
locked
|
||||||
: `Can be set via environment variable ${envVar}`}
|
? `Set via environment variable ${envVar} — edit your .env file to change`
|
||||||
|
: `Can be set via environment variable ${envVar}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{locked ? '🔒' : '🔓'} <span className="font-mono">{envVar}</span>
|
{locked ? "🔒" : "🔓"} <span className="font-mono">{envVar}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Section card ──────────────────────────────────────────────────────────────
|
// ─── 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 (
|
return (
|
||||||
<div className="border border-gray-200 rounded-lg p-4 mb-4">
|
<div className="border border-gray-200 rounded-lg p-4 mb-4">
|
||||||
<div className="font-semibold text-sm mb-1">{title}</div>
|
<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 ─────────────────────────────────────────────────────
|
// ─── Sortable language list ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SortableLanguageList({
|
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));
|
const available = LANGUAGE_OPTIONS.filter((o) => !langs.includes(o.code));
|
||||||
|
|
||||||
@@ -88,21 +105,32 @@ function SortableLanguageList({
|
|||||||
return (
|
return (
|
||||||
<div key={code} className="flex items-center gap-1.5 text-sm">
|
<div key={code} className="flex items-center gap-1.5 text-sm">
|
||||||
<button
|
<button
|
||||||
type="button" disabled={disabled || i === 0}
|
type="button"
|
||||||
|
disabled={disabled || i === 0}
|
||||||
onClick={() => move(i, -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"
|
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
|
<button
|
||||||
type="button" disabled={disabled || i === langs.length - 1}
|
type="button"
|
||||||
|
disabled={disabled || i === langs.length - 1}
|
||||||
onClick={() => move(i, 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"
|
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
|
<button
|
||||||
type="button" disabled={disabled}
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
onClick={() => remove(i)}
|
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"
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -111,12 +139,17 @@ function SortableLanguageList({
|
|||||||
{!disabled && available.length > 0 && (
|
{!disabled && available.length > 0 && (
|
||||||
<Select
|
<Select
|
||||||
value=""
|
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]"
|
className="text-sm max-w-[14rem]"
|
||||||
>
|
>
|
||||||
<option value="">+ Add language…</option>
|
<option value="">+ Add language…</option>
|
||||||
{available.map(({ code, label }) => (
|
{available.map(({ code, label }) => (
|
||||||
<option key={code} value={code}>{label} ({code})</option>
|
<option key={code} value={code}>
|
||||||
|
{label} ({code})
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
@@ -127,20 +160,38 @@ function SortableLanguageList({
|
|||||||
// ─── Connection section ────────────────────────────────────────────────────────
|
// ─── Connection section ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ConnSection({
|
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>;
|
title: React.ReactNode;
|
||||||
urlKey: string; apiKey: string; urlPlaceholder: string; onSave: (url: string, apiKey: string) => Promise<void>;
|
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 [url, setUrl] = useState(cfg[urlKey] ?? "");
|
||||||
const [key, setKey] = useState(cfg[apiKeyProp] ?? '');
|
const [key, setKey] = useState(cfg[apiKeyProp] ?? "");
|
||||||
const [status, setStatus] = useState<{ ok: boolean; error?: string } | null>(null);
|
const [status, setStatus] = useState<{ ok: boolean; error?: string } | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setStatus(null);
|
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);
|
setSaving(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -148,19 +199,32 @@ function ConnSection({
|
|||||||
<SectionCard title={title} subtitle={subtitle}>
|
<SectionCard title={title} subtitle={subtitle}>
|
||||||
<label className="block text-sm text-gray-700 mb-1">
|
<label className="block text-sm text-gray-700 mb-1">
|
||||||
URL
|
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>
|
||||||
<label className="block text-sm text-gray-700 mb-1 mt-3">
|
<label className="block text-sm text-gray-700 mb-1 mt-3">
|
||||||
API Key
|
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>
|
</label>
|
||||||
<div className="flex items-center gap-2 mt-3">
|
<div className="flex items-center gap-2 mt-3">
|
||||||
<Button onClick={save} disabled={saving || (locked.has(urlKey) && locked.has(apiKeyProp))}>
|
<Button onClick={save} disabled={saving || (locked.has(urlKey) && locked.has(apiKeyProp))}>
|
||||||
{saving ? 'Saving…' : 'Test & Save'}
|
{saving ? "Saving…" : "Test & Save"}
|
||||||
</Button>
|
</Button>
|
||||||
{status && (
|
{status && (
|
||||||
<span className={`text-sm ${status.ok ? 'text-green-700' : 'text-red-600'}`}>
|
<span className={`text-sm ${status.ok ? "text-green-700" : "text-red-600"}`}>
|
||||||
{status.ok ? '✓ Saved' : `✗ ${status.error ?? 'Connection failed'}`}
|
{status.ok ? "✓ Saved" : `✗ ${status.error ?? "Connection failed"}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -173,54 +237,61 @@ function ConnSection({
|
|||||||
export function SetupPage() {
|
export function SetupPage() {
|
||||||
const [data, setData] = useState<SetupData | null>(setupCache);
|
const [data, setData] = useState<SetupData | null>(setupCache);
|
||||||
const [loading, setLoading] = useState(setupCache === null);
|
const [loading, setLoading] = useState(setupCache === null);
|
||||||
const [clearStatus, setClearStatus] = useState('');
|
const [clearStatus, setClearStatus] = useState("");
|
||||||
const [subLangs, setSubLangs] = useState<string[]>([]);
|
const [subLangs, setSubLangs] = useState<string[]>([]);
|
||||||
const [subSaved, setSubSaved] = useState('');
|
const [subSaved, setSubSaved] = useState("");
|
||||||
const [audLangs, setAudLangs] = useState<string[]>([]);
|
const [audLangs, setAudLangs] = useState<string[]>([]);
|
||||||
const [audSaved, setAudSaved] = useState('');
|
const [audSaved, setAudSaved] = useState("");
|
||||||
const [langsLoaded, setLangsLoaded] = useState(false);
|
const [langsLoaded, setLangsLoaded] = useState(false);
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
if (!setupCache) setLoading(true);
|
if (!setupCache) setLoading(true);
|
||||||
api.get<SetupData>('/api/setup').then((d) => {
|
api
|
||||||
setupCache = d;
|
.get<SetupData>("/api/setup")
|
||||||
setData(d);
|
.then((d) => {
|
||||||
if (!langsLoaded) {
|
setupCache = d;
|
||||||
setSubLangs(JSON.parse(d.config.subtitle_languages ?? '["eng","deu","spa"]'));
|
setData(d);
|
||||||
setAudLangs(JSON.parse(d.config.audio_languages ?? '[]'));
|
if (!langsLoaded) {
|
||||||
setLangsLoaded(true);
|
setSubLangs(JSON.parse(d.config.subtitle_languages ?? '["eng","deu","spa"]'));
|
||||||
}
|
setAudLangs(JSON.parse(d.config.audio_languages ?? "[]"));
|
||||||
}).finally(() => setLoading(false));
|
setLangsLoaded(true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
if (loading && !data) return <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
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>;
|
if (!data) return <div className="text-red-600">Failed to load settings.</div>;
|
||||||
|
|
||||||
const { config: cfg, envLocked: envLockedArr } = data;
|
const { config: cfg, envLocked: envLockedArr } = data;
|
||||||
const locked = new Set(envLockedArr);
|
const locked = new Set(envLockedArr);
|
||||||
const saveJellyfin = (url: string, apiKey: string) =>
|
const saveJellyfin = (url: string, apiKey: string) => api.post("/api/setup/jellyfin", { url, api_key: apiKey });
|
||||||
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 saveRadarr = (url: string, apiKey: string) =>
|
const saveSonarr = (url: string, apiKey: string) => api.post("/api/setup/sonarr", { url, api_key: apiKey });
|
||||||
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 () => {
|
const saveSubtitleLangs = async () => {
|
||||||
await api.post('/api/setup/subtitle-languages', { langs: subLangs });
|
await api.post("/api/setup/subtitle-languages", { langs: subLangs });
|
||||||
setSubSaved('Saved.');
|
setSubSaved("Saved.");
|
||||||
setTimeout(() => setSubSaved(''), 2000);
|
setTimeout(() => setSubSaved(""), 2000);
|
||||||
};
|
};
|
||||||
const saveAudioLangs = async () => {
|
const saveAudioLangs = async () => {
|
||||||
await api.post('/api/setup/audio-languages', { langs: audLangs });
|
await api.post("/api/setup/audio-languages", { langs: audLangs });
|
||||||
setAudSaved('Saved.');
|
setAudSaved("Saved.");
|
||||||
setTimeout(() => setAudSaved(''), 2000);
|
setTimeout(() => setAudSaved(""), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearScan = async () => {
|
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;
|
if (
|
||||||
await api.post('/api/setup/clear-scan');
|
!confirm(
|
||||||
setClearStatus('Cleared.');
|
"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 (
|
return (
|
||||||
@@ -231,27 +302,53 @@ export function SetupPage() {
|
|||||||
|
|
||||||
{/* Jellyfin */}
|
{/* Jellyfin */}
|
||||||
<ConnSection
|
<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>}
|
title={
|
||||||
urlKey="jellyfin_url" apiKey="jellyfin_api_key"
|
<span className="flex items-center gap-2">
|
||||||
urlPlaceholder="http://192.168.1.100:8096" cfg={cfg} locked={locked}
|
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}
|
onSave={saveJellyfin}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Radarr */}
|
{/* Radarr */}
|
||||||
<ConnSection
|
<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."
|
subtitle="Provides accurate original-language data for movies."
|
||||||
urlKey="radarr_url" apiKey="radarr_api_key"
|
urlKey="radarr_url"
|
||||||
urlPlaceholder="http://192.168.1.100:7878" cfg={cfg} locked={locked}
|
apiKey="radarr_api_key"
|
||||||
|
urlPlaceholder="http://192.168.1.100:7878"
|
||||||
|
cfg={cfg}
|
||||||
|
locked={locked}
|
||||||
onSave={saveRadarr}
|
onSave={saveRadarr}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Sonarr */}
|
{/* Sonarr */}
|
||||||
<ConnSection
|
<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."
|
subtitle="Provides original-language data for TV series."
|
||||||
urlKey="sonarr_url" apiKey="sonarr_api_key"
|
urlKey="sonarr_url"
|
||||||
urlPlaceholder="http://192.168.1.100:8989" cfg={cfg} locked={locked}
|
apiKey="sonarr_api_key"
|
||||||
|
urlPlaceholder="http://192.168.1.100:8989"
|
||||||
|
cfg={cfg}
|
||||||
|
locked={locked}
|
||||||
onSave={saveSonarr}
|
onSave={saveSonarr}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -260,14 +357,16 @@ export function SetupPage() {
|
|||||||
title={
|
title={
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
Audio Languages
|
Audio Languages
|
||||||
<EnvBadge envVar="AUDIO_LANGUAGES" locked={locked.has('audio_languages')} />
|
<EnvBadge envVar="AUDIO_LANGUAGES" locked={locked.has("audio_languages")} />
|
||||||
</span>
|
</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."
|
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">
|
<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>}
|
{audSaved && <span className="text-green-700 text-sm">{audSaved}</span>}
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
@@ -277,14 +376,16 @@ export function SetupPage() {
|
|||||||
title={
|
title={
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
Subtitle Languages
|
Subtitle Languages
|
||||||
<EnvBadge envVar="SUBTITLE_LANGUAGES" locked={locked.has('subtitle_languages')} />
|
<EnvBadge envVar="SUBTITLE_LANGUAGES" locked={locked.has("subtitle_languages")} />
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
subtitle="Subtitle tracks in these languages are extracted to sidecar files. Order determines priority. All subtitles are removed from the container during processing."
|
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">
|
<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>}
|
{subSaved && <span className="text-green-700 text-sm">{subSaved}</span>}
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
@@ -292,9 +393,13 @@ export function SetupPage() {
|
|||||||
{/* Danger zone */}
|
{/* Danger zone */}
|
||||||
<div className="border border-red-400 rounded-lg p-4 mb-4">
|
<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>
|
<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">
|
<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>
|
<span className="text-gray-400 text-sm">Removes all scanned items, review plans, and jobs.</span>
|
||||||
</div>
|
</div>
|
||||||
{clearStatus && <p className="text-green-700 text-sm mt-2">{clearStatus}</p>}
|
{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 { Link, useParams } from '@tanstack/react-router';
|
import { useEffect, useState } from "react";
|
||||||
import { api } from '~/shared/lib/api';
|
import { Alert } from "~/shared/components/ui/alert";
|
||||||
import { Badge } from '~/shared/components/ui/badge';
|
import { Badge } from "~/shared/components/ui/badge";
|
||||||
import { Button } from '~/shared/components/ui/button';
|
import { Button } from "~/shared/components/ui/button";
|
||||||
import { Alert } from '~/shared/components/ui/alert';
|
import { Select } from "~/shared/components/ui/select";
|
||||||
import { Select } from '~/shared/components/ui/select';
|
import { api } from "~/shared/lib/api";
|
||||||
import { langName, LANG_NAMES } from '~/shared/lib/lang';
|
import { LANG_NAMES, langName } from "~/shared/lib/lang";
|
||||||
import type { MediaItem, MediaStream, SubtitleFile, ReviewPlan, StreamDecision } from '~/shared/lib/types';
|
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision, SubtitleFile } from "~/shared/lib/types";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -30,12 +30,12 @@ function formatBytes(bytes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fileName(filePath: string): string {
|
function fileName(filePath: string): string {
|
||||||
return filePath.split('/').pop() ?? filePath;
|
return filePath.split("/").pop() ?? filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
function effectiveTitle(s: MediaStream, dec: StreamDecision | undefined): string {
|
function effectiveTitle(s: MediaStream, dec: StreamDecision | undefined): string {
|
||||||
if (dec?.custom_title) return dec.custom_title;
|
if (dec?.custom_title) return dec.custom_title;
|
||||||
if (!s.language) return '';
|
if (!s.language) return "";
|
||||||
const base = langName(s.language);
|
const base = langName(s.language);
|
||||||
if (s.is_forced) return `${base} (Forced)`;
|
if (s.is_forced) return `${base} (Forced)`;
|
||||||
if (s.is_hearing_impaired) return `${base} (CC)`;
|
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 }) {
|
function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
|
||||||
const [localVal, setLocalVal] = useState(value);
|
const [localVal, setLocalVal] = useState(value);
|
||||||
useEffect(() => { setLocalVal(value); }, [value]);
|
useEffect(() => {
|
||||||
|
setLocalVal(value);
|
||||||
|
}, [value]);
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={localVal}
|
value={localVal}
|
||||||
onChange={(e) => setLocalVal(e.target.value)}
|
onChange={(e) => setLocalVal(e.target.value)}
|
||||||
onBlur={(e) => { if (e.target.value !== value) onCommit(e.target.value); }}
|
onBlur={(e) => {
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
|
if (e.target.value !== value) onCommit(e.target.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||||
|
}}
|
||||||
placeholder="—"
|
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"
|
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,72 +80,79 @@ 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>;
|
if (streams.length === 0) return <p className="text-gray-500 text-sm">No subtitle streams in container.</p>;
|
||||||
|
|
||||||
return (
|
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">
|
||||||
<thead>
|
<table className="w-full border-collapse text-[0.79rem] mt-1">
|
||||||
<tr>
|
<thead>
|
||||||
{['#', 'Codec', 'Language', 'Title / Info', 'Flags', 'Action'].map((h) => (
|
<tr>
|
||||||
<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
|
||||||
</tr>
|
key={h}
|
||||||
</thead>
|
className="text-left text-[0.67rem] uppercase tracking-[0.05em] text-gray-500 py-1 px-2 border-b border-gray-200"
|
||||||
<tbody>
|
>
|
||||||
{streams.map((s) => {
|
{h}
|
||||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
</th>
|
||||||
const title = effectiveTitle(s, dec);
|
))}
|
||||||
const origTitle = s.title;
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{streams.map((s) => {
|
||||||
|
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||||
|
const title = effectiveTitle(s, dec);
|
||||||
|
const origTitle = s.title;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={s.id} className="bg-sky-50">
|
<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.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">
|
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||||
{editable ? (
|
{editable ? (
|
||||||
<Select
|
<Select
|
||||||
value={s.language ?? ''}
|
value={s.language ?? ""}
|
||||||
onChange={(e) => onLanguageChange(s.id, e.target.value)}
|
onChange={(e) => onLanguageChange(s.id, e.target.value)}
|
||||||
className="text-[0.79rem] py-0.5 px-1.5 w-auto"
|
className="text-[0.79rem] py-0.5 px-1.5 w-auto"
|
||||||
>
|
>
|
||||||
<option value="">— Unknown —</option>
|
<option value="">— Unknown —</option>
|
||||||
{Object.entries(LANG_NAMES).map(([code, name]) => (
|
{Object.entries(LANG_NAMES).map(([code, name]) => (
|
||||||
<option key={code} value={code}>{name} ({code})</option>
|
<option key={code} value={code}>
|
||||||
))}
|
{name} ({code})
|
||||||
</Select>
|
</option>
|
||||||
) : (
|
))}
|
||||||
<>
|
</Select>
|
||||||
{langName(s.language)} {s.language ? <span className="text-gray-400 font-mono text-xs">({s.language})</span> : null}
|
) : (
|
||||||
</>
|
<>
|
||||||
)}
|
{langName(s.language)}{" "}
|
||||||
</td>
|
{s.language ? <span className="text-gray-400 font-mono text-xs">({s.language})</span> : null}
|
||||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
</>
|
||||||
{editable ? (
|
)}
|
||||||
<TitleInput
|
</td>
|
||||||
value={title}
|
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||||
onCommit={(v) => onTitleChange(s.id, v)}
|
{editable ? (
|
||||||
/>
|
<TitleInput value={title} onCommit={(v) => onTitleChange(s.id, v)} />
|
||||||
) : (
|
) : (
|
||||||
<span>{title || '—'}</span>
|
<span>{title || "—"}</span>
|
||||||
)}
|
)}
|
||||||
{editable && origTitle && origTitle !== title && (
|
{editable && origTitle && origTitle !== title && (
|
||||||
<div className="text-gray-400 text-[0.7rem] mt-0.5">orig: {origTitle}</div>
|
<div className="text-gray-400 text-[0.7rem] mt-0.5">orig: {origTitle}</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||||
<span className="inline-flex gap-1">
|
<span className="inline-flex gap-1">
|
||||||
{s.is_default ? <Badge>default</Badge> : null}
|
{s.is_default ? <Badge>default</Badge> : null}
|
||||||
{s.is_forced ? <Badge variant="manual">forced</Badge> : null}
|
{s.is_forced ? <Badge variant="manual">forced</Badge> : null}
|
||||||
{s.is_hearing_impaired ? <Badge>CC</Badge> : null}
|
{s.is_hearing_impaired ? <Badge>CC</Badge> : null}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||||
<span className="inline-block border-0 rounded px-2 py-0.5 text-[0.72rem] font-semibold bg-sky-600 text-white min-w-[4.5rem]">
|
<span className="inline-block border-0 rounded px-2 py-0.5 text-[0.72rem] font-semibold bg-sky-600 text-white min-w-[4.5rem]">
|
||||||
↑ Extract
|
↑ Extract
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table></div>
|
</table>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,54 +162,76 @@ function ExtractedFilesTable({ files, onDelete }: { files: SubtitleFile[]; onDel
|
|||||||
if (files.length === 0) return <p className="text-gray-500 text-sm">No extracted files yet.</p>;
|
if (files.length === 0) return <p className="text-gray-500 text-sm">No extracted files yet.</p>;
|
||||||
|
|
||||||
return (
|
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">
|
||||||
<thead>
|
<table className="w-full border-collapse text-[0.82rem]">
|
||||||
<tr>
|
<thead>
|
||||||
{['File', 'Language', 'Codec', 'Flags', 'Size', ''].map((h) => (
|
<tr>
|
||||||
<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
|
||||||
</tr>
|
key={h}
|
||||||
</thead>
|
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"
|
||||||
<tbody>
|
>
|
||||||
{files.map((f) => (
|
{h}
|
||||||
<tr key={f.id} className="hover:bg-gray-50">
|
</th>
|
||||||
<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}
|
|
||||||
</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}
|
|
||||||
{f.is_hearing_impaired ? <Badge>CC</Badge> : null}
|
|
||||||
</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) : '—'}
|
|
||||||
</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>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table></div>
|
{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}
|
||||||
|
>
|
||||||
|
{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}
|
||||||
|
</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}
|
||||||
|
{f.is_hearing_impaired ? <Badge>CC</Badge> : null}
|
||||||
|
</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) : "—"}
|
||||||
|
</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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Detail page ──────────────────────────────────────────────────────────────
|
// ─── Detail page ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function SubtitleDetailPage() {
|
export function SubtitleDetailPage() {
|
||||||
const { id } = useParams({ from: '/review/subtitles/$id' });
|
const { id } = useParams({ from: "/review/subtitles/$id" });
|
||||||
const [data, setData] = useState<DetailData | null>(null);
|
const [data, setData] = useState<DetailData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [extracting, setExtracting] = useState(false);
|
const [extracting, setExtracting] = useState(false);
|
||||||
const [rescanning, setRescanning] = useState(false);
|
const [rescanning, setRescanning] = useState(false);
|
||||||
|
|
||||||
const load = () => api.get<DetailData>(`/api/subtitles/${id}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
|
const load = () =>
|
||||||
useEffect(() => { load(); }, [id]);
|
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 changeLanguage = async (streamId: number, lang: string) => {
|
||||||
const d = await api.patch<DetailData>(`/api/subtitles/${id}/stream/${streamId}/language`, { language: lang || null });
|
const d = await api.patch<DetailData>(`/api/subtitles/${id}/stream/${streamId}/language`, { language: lang || null });
|
||||||
@@ -213,7 +248,9 @@ export function SubtitleDetailPage() {
|
|||||||
try {
|
try {
|
||||||
await api.post(`/api/subtitles/${id}/extract`);
|
await api.post(`/api/subtitles/${id}/extract`);
|
||||||
load();
|
load();
|
||||||
} finally { setExtracting(false); }
|
} finally {
|
||||||
|
setExtracting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteFile = async (fileId: number) => {
|
const deleteFile = async (fileId: number) => {
|
||||||
@@ -223,8 +260,12 @@ export function SubtitleDetailPage() {
|
|||||||
|
|
||||||
const rescan = async () => {
|
const rescan = async () => {
|
||||||
setRescanning(true);
|
setRescanning(true);
|
||||||
try { const d = await api.post<DetailData>(`/api/subtitles/${id}/rescan`); setData(d); }
|
try {
|
||||||
finally { setRescanning(false); }
|
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>;
|
if (loading) return <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
||||||
@@ -238,7 +279,9 @@ export function SubtitleDetailPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<h1 className="text-xl font-bold m-0">
|
<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}
|
{item.name}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,12 +290,15 @@ export function SubtitleDetailPage() {
|
|||||||
{/* Meta */}
|
{/* Meta */}
|
||||||
<dl className="flex flex-wrap gap-5 mb-3 text-[0.82rem]">
|
<dl className="flex flex-wrap gap-5 mb-3 text-[0.82rem]">
|
||||||
{[
|
{[
|
||||||
{ label: 'Type', value: item.type },
|
{ label: "Type", value: item.type },
|
||||||
...(item.series_name ? [{ label: 'Series', value: item.series_name }] : []),
|
...(item.series_name ? [{ label: "Series", value: item.series_name }] : []),
|
||||||
...(item.year ? [{ label: 'Year', value: String(item.year) }] : []),
|
...(item.year ? [{ label: "Year", value: String(item.year) }] : []),
|
||||||
{ label: 'Container', value: item.container ?? '—' },
|
{ label: "Container", value: item.container ?? "—" },
|
||||||
{ label: 'File size', value: item.file_size ? formatBytes(item.file_size) : '—' },
|
{ 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: "Status",
|
||||||
|
value: <Badge variant={subs_extracted ? "done" : "pending"}>{subs_extracted ? "extracted" : "pending"}</Badge>,
|
||||||
|
},
|
||||||
].map((entry, i) => (
|
].map((entry, i) => (
|
||||||
<div key={i}>
|
<div key={i}>
|
||||||
<dt className="text-gray-500 text-[0.68rem] uppercase tracking-[0.05em] mb-0.5">{entry.label}</dt>
|
<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}
|
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 */}
|
{/* Extracted files */}
|
||||||
@@ -301,22 +349,26 @@ export function SubtitleDetailPage() {
|
|||||||
{hasContainerSubs && !subs_extracted && (
|
{hasContainerSubs && !subs_extracted && (
|
||||||
<div className="flex gap-2 mt-6">
|
<div className="flex gap-2 mt-6">
|
||||||
<Button onClick={extract} disabled={extracting}>
|
<Button onClick={extract} disabled={extracting}>
|
||||||
{extracting ? 'Queuing…' : '✓ Extract All'}
|
{extracting ? "Queuing…" : "✓ Extract All"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{subs_extracted ? (
|
{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}
|
) : null}
|
||||||
|
|
||||||
{/* Refresh */}
|
{/* Refresh */}
|
||||||
<div className="flex items-center gap-3 mt-6 pt-3 border-t border-gray-200">
|
<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}>
|
<Button variant="secondary" size="sm" onClick={rescan} disabled={rescanning}>
|
||||||
{rescanning ? '↻ Refreshing…' : '↻ Refresh from Jellyfin'}
|
{rescanning ? "↻ Refreshing…" : "↻ Refresh from Jellyfin"}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-gray-400 text-[0.75rem]">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +1,37 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
|
import type React from "react";
|
||||||
import { api } from '~/shared/lib/api';
|
import { useEffect, useState } from "react";
|
||||||
import { Badge } from '~/shared/components/ui/badge';
|
import { Badge } from "~/shared/components/ui/badge";
|
||||||
import { Button } from '~/shared/components/ui/button';
|
import { Button } from "~/shared/components/ui/button";
|
||||||
import { FilterTabs } from '~/shared/components/ui/filter-tabs';
|
import { FilterTabs } from "~/shared/components/ui/filter-tabs";
|
||||||
import { langName } from '~/shared/lib/lang';
|
import { api } from "~/shared/lib/api";
|
||||||
import type React from 'react';
|
import { langName } from "~/shared/lib/lang";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface SubListItem {
|
interface SubListItem {
|
||||||
id: number; name: string; type: string; series_name: string | null;
|
id: number;
|
||||||
season_number: number | null; episode_number: number | null;
|
name: string;
|
||||||
year: number | null; original_language: string | null;
|
type: string;
|
||||||
subs_extracted: number | null; sub_count: number; file_count: number;
|
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 {
|
interface SubSeriesGroup {
|
||||||
series_key: string; series_name: string; original_language: string | null;
|
series_key: string;
|
||||||
season_count: number; episode_count: number;
|
series_name: string;
|
||||||
not_extracted_count: number; extracted_count: number; no_subs_count: number;
|
original_language: string | null;
|
||||||
|
season_count: number;
|
||||||
|
episode_count: number;
|
||||||
|
not_extracted_count: number;
|
||||||
|
extracted_count: number;
|
||||||
|
no_subs_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SubListData {
|
interface SubListData {
|
||||||
@@ -32,14 +44,16 @@ interface SubListData {
|
|||||||
interface SeasonGroup {
|
interface SeasonGroup {
|
||||||
season: number | null;
|
season: number | null;
|
||||||
episodes: SubListItem[];
|
episodes: SubListItem[];
|
||||||
extractedCount: number; notExtractedCount: number; noSubsCount: number;
|
extractedCount: number;
|
||||||
|
notExtractedCount: number;
|
||||||
|
noSubsCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FILTER_TABS = [
|
const FILTER_TABS = [
|
||||||
{ key: 'all', label: 'All' },
|
{ key: "all", label: "All" },
|
||||||
{ key: 'not_extracted', label: 'Not Extracted' },
|
{ key: "not_extracted", label: "Not Extracted" },
|
||||||
{ key: 'extracted', label: 'Extracted' },
|
{ key: "extracted", label: "Extracted" },
|
||||||
{ key: 'no_subs', label: 'No Subtitles' },
|
{ key: "no_subs", label: "No Subtitles" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Table helpers ────────────────────────────────────────────────────────────
|
// ─── Table helpers ────────────────────────────────────────────────────────────
|
||||||
@@ -51,27 +65,39 @@ const Th = ({ children }: { children?: React.ReactNode }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const Td = ({ children, className }: { children?: React.ReactNode; className?: string }) => (
|
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' {
|
function subStatus(item: SubListItem): "extracted" | "not_extracted" | "no_subs" {
|
||||||
if (item.sub_count === 0) return 'no_subs';
|
if (item.sub_count === 0) return "no_subs";
|
||||||
return item.subs_extracted ? 'extracted' : 'not_extracted';
|
return item.subs_extracted ? "extracted" : "not_extracted";
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ item }: { item: SubListItem }) {
|
function StatusBadge({ item }: { item: SubListItem }) {
|
||||||
const s = subStatus(item);
|
const s = subStatus(item);
|
||||||
if (s === 'extracted') return <Badge variant="keep">extracted</Badge>;
|
if (s === "extracted") return <Badge variant="keep">extracted</Badge>;
|
||||||
if (s === 'not_extracted') return <Badge variant="pending">pending</Badge>;
|
if (s === "not_extracted") return <Badge variant="pending">pending</Badge>;
|
||||||
return <Badge variant="noop">no subs</Badge>;
|
return <Badge variant="noop">no subs</Badge>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusPills({ g }: { g: SubSeriesGroup }) {
|
function StatusPills({ g }: { g: SubSeriesGroup }) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex flex-wrap gap-1 items-center">
|
<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.extracted_count > 0 && (
|
||||||
{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>}
|
<span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-green-100 text-green-800">
|
||||||
{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} 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>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,16 +106,18 @@ function StatusPills({ g }: { g: SubSeriesGroup }) {
|
|||||||
|
|
||||||
function ActionBox({ count, onExtract }: { count: number | null; onExtract: () => void }) {
|
function ActionBox({ count, onExtract }: { count: number | null; onExtract: () => void }) {
|
||||||
const [extracting, setExtracting] = useState(false);
|
const [extracting, setExtracting] = useState(false);
|
||||||
const [result, setResult] = useState('');
|
const [result, setResult] = useState("");
|
||||||
|
|
||||||
const handleExtract = async () => {
|
const handleExtract = async () => {
|
||||||
setExtracting(true);
|
setExtracting(true);
|
||||||
setResult('');
|
setResult("");
|
||||||
try {
|
try {
|
||||||
const r = await api.post<{ ok: boolean; queued: number }>('/api/subtitles/extract-all');
|
const r = await api.post<{ ok: boolean; queued: number }>("/api/subtitles/extract-all");
|
||||||
setResult(`Queued ${r.queued} extraction job${r.queued !== 1 ? 's' : ''}.`);
|
setResult(`Queued ${r.queued} extraction job${r.queued !== 1 ? "s" : ""}.`);
|
||||||
onExtract();
|
onExtract();
|
||||||
} catch (e) { setResult(`Error: ${e}`); }
|
} catch (e) {
|
||||||
|
setResult(`Error: ${e}`);
|
||||||
|
}
|
||||||
setExtracting(false);
|
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>}
|
{allDone && <span className="text-sm font-medium">All subtitles extracted</span>}
|
||||||
{count !== null && count > 0 && (
|
{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}>
|
<Button size="sm" onClick={handleExtract} disabled={extracting}>
|
||||||
{extracting ? 'Queuing...' : 'Extract All'}
|
{extracting ? "Queuing..." : "Extract All"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -131,13 +161,19 @@ function SeriesRow({ g }: { g: SubSeriesGroup }) {
|
|||||||
<tbody>
|
<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">
|
<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>
|
<span
|
||||||
{' '}<strong>{g.series_name}</strong>
|
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>
|
||||||
<Td>{langName(g.original_language)}</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.season_count}</td>
|
||||||
<td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.episode_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>
|
</tr>
|
||||||
{open && seasons && (
|
{open && seasons && (
|
||||||
<tr>
|
<tr>
|
||||||
@@ -147,21 +183,41 @@ function SeriesRow({ g }: { g: SubSeriesGroup }) {
|
|||||||
{seasons.map((s) => (
|
{seasons.map((s) => (
|
||||||
<>
|
<>
|
||||||
<tr key={`season-${s.season}`} className="bg-gray-50">
|
<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">
|
<td
|
||||||
Season {s.season ?? '?'}
|
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">
|
<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.extractedCount > 0 && (
|
||||||
{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>}
|
<span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-800 text-[0.7rem]">
|
||||||
{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} 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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{s.episodes.map((item) => (
|
{s.episodes.map((item) => (
|
||||||
<tr key={item.id} className="hover:bg-gray-50">
|
<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">
|
<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")}
|
||||||
<Link to="/review/subtitles/$id" params={{ id: String(item.id) }} className="no-underline text-blue-600 hover:text-blue-800">
|
</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>
|
<span className="truncate inline-block max-w-xs align-bottom">{item.name}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
@@ -171,7 +227,11 @@ function SeriesRow({ g }: { g: SubSeriesGroup }) {
|
|||||||
<StatusBadge item={item} />
|
<StatusBadge item={item} />
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1 px-2 border-b border-gray-100 whitespace-nowrap">
|
<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
|
Detail
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
@@ -195,7 +255,7 @@ const cache = new Map<string, SubListData>();
|
|||||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function SubtitleExtractPage() {
|
export function SubtitleExtractPage() {
|
||||||
const { filter } = useSearch({ from: '/review/subtitles/extract' });
|
const { filter } = useSearch({ from: "/review/subtitles/extract" });
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [data, setData] = useState<SubListData | null>(cache.get(filter) ?? null);
|
const [data, setData] = useState<SubListData | null>(cache.get(filter) ?? null);
|
||||||
const [loading, setLoading] = useState(!cache.has(filter));
|
const [loading, setLoading] = useState(!cache.has(filter));
|
||||||
@@ -203,21 +263,33 @@ export function SubtitleExtractPage() {
|
|||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
if (!cache.has(filter)) setLoading(true);
|
if (!cache.has(filter)) setLoading(true);
|
||||||
api.get<SubListData>(`/api/subtitles?filter=${filter}`)
|
api
|
||||||
.then((d) => { cache.set(filter, d); setData(d); })
|
.get<SubListData>(`/api/subtitles?filter=${filter}`)
|
||||||
|
.then((d) => {
|
||||||
|
cache.set(filter, d);
|
||||||
|
setData(d);
|
||||||
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadEmbedded = () => {
|
const loadEmbedded = () => {
|
||||||
api.get<{ embeddedCount: number }>('/api/subtitles/summary')
|
api
|
||||||
|
.get<{ embeddedCount: number }>("/api/subtitles/summary")
|
||||||
.then((d) => setEmbeddedCount(d.embeddedCount))
|
.then((d) => setEmbeddedCount(d.embeddedCount))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { load(); loadEmbedded(); }, [filter]);
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
loadEmbedded();
|
||||||
|
}, [load, loadEmbedded]);
|
||||||
|
|
||||||
const refresh = () => { cache.clear(); load(); loadEmbedded(); };
|
const refresh = () => {
|
||||||
|
cache.clear();
|
||||||
|
load();
|
||||||
|
loadEmbedded();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -229,7 +301,7 @@ export function SubtitleExtractPage() {
|
|||||||
tabs={FILTER_TABS}
|
tabs={FILTER_TABS}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
totalCounts={data?.totalCounts ?? {}}
|
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>}
|
{loading && !data && <div className="text-gray-400 py-4 text-center text-sm">Loading...</div>}
|
||||||
@@ -247,20 +319,36 @@ export function SubtitleExtractPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-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]">
|
<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>
|
<tbody>
|
||||||
{data.movies.map((item) => (
|
{data.movies.map((item) => (
|
||||||
<tr key={item.id} className="hover:bg-gray-50">
|
<tr key={item.id} className="hover:bg-gray-50">
|
||||||
<Td>
|
<Td>
|
||||||
<Link to="/review/subtitles/$id" params={{ id: String(item.id) }} className="no-underline text-blue-600 hover:text-blue-800">
|
<Link
|
||||||
<span className="truncate inline-block max-w-[200px] sm:max-w-[360px]" title={item.name}>{item.name}</span>
|
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>
|
</Link>
|
||||||
{item.year && <span className="text-gray-400 text-[0.72rem]"> ({item.year})</span>}
|
{item.year && <span className="text-gray-400 text-[0.72rem]"> ({item.year})</span>}
|
||||||
</Td>
|
</Td>
|
||||||
<Td>{langName(item.original_language)}</Td>
|
<Td>{langName(item.original_language)}</Td>
|
||||||
<Td className="font-mono text-xs">{item.sub_count}</Td>
|
<Td className="font-mono text-xs">{item.sub_count}</Td>
|
||||||
<Td className="font-mono text-xs">{item.file_count}</Td>
|
<Td className="font-mono text-xs">{item.file_count}</Td>
|
||||||
<Td><StatusBadge item={item} /></Td>
|
<Td>
|
||||||
|
<StatusBadge item={item} />
|
||||||
|
</Td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -271,13 +359,25 @@ export function SubtitleExtractPage() {
|
|||||||
|
|
||||||
{data.series.length > 0 && (
|
{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>
|
TV Series <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{data.series.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-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]">
|
<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>
|
<thead>
|
||||||
{data.series.map((g) => <SeriesRow key={g.series_key} g={g} />)}
|
<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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import type React from "react";
|
||||||
import { api } from '~/shared/lib/api';
|
import { useEffect, useState } from "react";
|
||||||
import { Button } from '~/shared/components/ui/button';
|
import { Button } from "~/shared/components/ui/button";
|
||||||
import { langName } from '~/shared/lib/lang';
|
import { api } from "~/shared/lib/api";
|
||||||
import type React from 'react';
|
import { langName } from "~/shared/lib/lang";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface SummaryCategory {
|
interface SummaryCategory {
|
||||||
language: string | null;
|
language: string | null;
|
||||||
variant: 'standard' | 'forced' | 'cc';
|
variant: "standard" | "forced" | "cc";
|
||||||
streamCount: number;
|
streamCount: number;
|
||||||
fileCount: number;
|
fileCount: number;
|
||||||
}
|
}
|
||||||
@@ -36,18 +36,22 @@ const Th = ({ children }: { children?: React.ReactNode }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const Td = ({ children, className }: { children?: React.ReactNode; className?: string }) => (
|
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 ───────────────────────────────────────────────────
|
// ─── Language summary table ───────────────────────────────────────────────────
|
||||||
|
|
||||||
function variantLabel(v: string): string {
|
function variantLabel(v: string): string {
|
||||||
if (v === 'forced') return 'Forced';
|
if (v === "forced") return "Forced";
|
||||||
if (v === 'cc') return 'CC';
|
if (v === "cc") return "CC";
|
||||||
return 'Standard';
|
return "Standard";
|
||||||
}
|
}
|
||||||
|
|
||||||
function LanguageSummary({ categories, keepLanguages, onDelete }: {
|
function LanguageSummary({
|
||||||
|
categories,
|
||||||
|
keepLanguages,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
categories: SummaryCategory[];
|
categories: SummaryCategory[];
|
||||||
keepLanguages: string[];
|
keepLanguages: string[];
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
@@ -57,21 +61,21 @@ function LanguageSummary({ categories, keepLanguages, onDelete }: {
|
|||||||
const [checked, setChecked] = useState<Record<string, boolean>>(() => {
|
const [checked, setChecked] = useState<Record<string, boolean>>(() => {
|
||||||
const init: Record<string, boolean> = {};
|
const init: Record<string, boolean> = {};
|
||||||
for (const cat of categories) {
|
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);
|
init[key] = cat.language !== null && keepSet.has(cat.language);
|
||||||
}
|
}
|
||||||
return init;
|
return init;
|
||||||
});
|
});
|
||||||
|
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [result, setResult] = useState('');
|
const [result, setResult] = useState("");
|
||||||
|
|
||||||
if (categories.length === 0) return null;
|
if (categories.length === 0) return null;
|
||||||
|
|
||||||
const toggle = (key: string) => setChecked((prev) => ({ ...prev, [key]: !prev[key] }));
|
const toggle = (key: string) => setChecked((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
|
||||||
const uncheckedCategories = categories.filter((cat) => {
|
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;
|
return !checked[key] && cat.fileCount > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,12 +86,14 @@ function LanguageSummary({ categories, keepLanguages, onDelete }: {
|
|||||||
variant: cat.variant,
|
variant: cat.variant,
|
||||||
}));
|
}));
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
setResult('');
|
setResult("");
|
||||||
try {
|
try {
|
||||||
const r = await api.post<{ ok: boolean; deleted: number }>('/api/subtitles/batch-delete', { categories: toDelete });
|
const r = await api.post<{ ok: boolean; deleted: number }>("/api/subtitles/batch-delete", { categories: toDelete });
|
||||||
setResult(`Deleted ${r.deleted} file${r.deleted !== 1 ? 's' : ''}.`);
|
setResult(`Deleted ${r.deleted} file${r.deleted !== 1 ? "s" : ""}.`);
|
||||||
onDelete();
|
onDelete();
|
||||||
} catch (e) { setResult(`Error: ${e}`); }
|
} catch (e) {
|
||||||
|
setResult(`Error: ${e}`);
|
||||||
|
}
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,7 +113,7 @@ function LanguageSummary({ categories, keepLanguages, onDelete }: {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{categories.map((cat) => {
|
{categories.map((cat) => {
|
||||||
const key = `${cat.language ?? '__null__'}|${cat.variant}`;
|
const key = `${cat.language ?? "__null__"}|${cat.variant}`;
|
||||||
return (
|
return (
|
||||||
<tr key={key} className="hover:bg-gray-50">
|
<tr key={key} className="hover:bg-gray-50">
|
||||||
<Td>
|
<Td>
|
||||||
@@ -129,17 +135,13 @@ function LanguageSummary({ categories, keepLanguages, onDelete }: {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 mt-2">
|
<div className="flex items-center gap-3 mt-2">
|
||||||
<Button
|
<Button size="sm" variant="danger" onClick={handleDelete} disabled={deleting || uncheckedCategories.length === 0}>
|
||||||
size="sm"
|
{deleting ? "Deleting..." : "Delete Unchecked Files"}
|
||||||
variant="danger"
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={deleting || uncheckedCategories.length === 0}
|
|
||||||
>
|
|
||||||
{deleting ? 'Deleting...' : 'Delete Unchecked Files'}
|
|
||||||
</Button>
|
</Button>
|
||||||
{uncheckedCategories.length > 0 && (
|
{uncheckedCategories.length > 0 && (
|
||||||
<span className="text-xs text-gray-500">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
{result && <span className="text-sm text-gray-600">{result}</span>}
|
{result && <span className="text-sm text-gray-600">{result}</span>}
|
||||||
@@ -150,24 +152,23 @@ function LanguageSummary({ categories, keepLanguages, onDelete }: {
|
|||||||
|
|
||||||
// ─── Title harmonization ──────────────────────────────────────────────────────
|
// ─── Title harmonization ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function TitleHarmonization({ titles, onNormalize }: {
|
function TitleHarmonization({ titles, onNormalize }: { titles: SummaryTitle[]; onNormalize: () => void }) {
|
||||||
titles: SummaryTitle[];
|
|
||||||
onNormalize: () => void;
|
|
||||||
}) {
|
|
||||||
const [normalizing, setNormalizing] = useState(false);
|
const [normalizing, setNormalizing] = useState(false);
|
||||||
const [result, setResult] = useState('');
|
const [result, setResult] = useState("");
|
||||||
|
|
||||||
const nonCanonical = titles.filter((t) => !t.isCanonical);
|
const nonCanonical = titles.filter((t) => !t.isCanonical);
|
||||||
if (nonCanonical.length === 0) return null;
|
if (nonCanonical.length === 0) return null;
|
||||||
|
|
||||||
const handleNormalizeAll = async () => {
|
const handleNormalizeAll = async () => {
|
||||||
setNormalizing(true);
|
setNormalizing(true);
|
||||||
setResult('');
|
setResult("");
|
||||||
try {
|
try {
|
||||||
const r = await api.post<{ ok: boolean; normalized: number }>('/api/subtitles/normalize-titles');
|
const r = await api.post<{ ok: boolean; normalized: number }>("/api/subtitles/normalize-titles");
|
||||||
setResult(`Normalized ${r.normalized} stream${r.normalized !== 1 ? 's' : ''}.`);
|
setResult(`Normalized ${r.normalized} stream${r.normalized !== 1 ? "s" : ""}.`);
|
||||||
onNormalize();
|
onNormalize();
|
||||||
} catch (e) { setResult(`Error: ${e}`); }
|
} catch (e) {
|
||||||
|
setResult(`Error: ${e}`);
|
||||||
|
}
|
||||||
setNormalizing(false);
|
setNormalizing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -181,7 +182,8 @@ function TitleHarmonization({ titles, onNormalize }: {
|
|||||||
return (
|
return (
|
||||||
<details className="mb-6">
|
<details className="mb-6">
|
||||||
<summary className="text-sm font-bold uppercase tracking-wide text-gray-500 mb-2 cursor-pointer select-none">
|
<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>
|
</summary>
|
||||||
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0 mt-2">
|
<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]">
|
<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">
|
<tr key={`${lang}|${t.title}`} className="hover:bg-gray-50">
|
||||||
<Td>{langName(lang)}</Td>
|
<Td>{langName(lang)}</Td>
|
||||||
<Td>
|
<Td>
|
||||||
<span className={`font-mono text-xs ${t.isCanonical ? 'text-gray-900' : 'text-amber-700'}`}>
|
<span className={`font-mono text-xs ${t.isCanonical ? "text-gray-900" : "text-amber-700"}`}>
|
||||||
{t.title ? `"${t.title}"` : '(none)'}
|
{t.title ? `"${t.title}"` : "(none)"}
|
||||||
</span>
|
</span>
|
||||||
{t.isCanonical && <span className="ml-2 text-[0.68rem] text-gray-400">(canonical)</span>}
|
{t.isCanonical && <span className="ml-2 text-[0.68rem] text-gray-400">(canonical)</span>}
|
||||||
</Td>
|
</Td>
|
||||||
<Td className="font-mono text-xs">{t.count}</Td>
|
<Td className="font-mono text-xs">{t.count}</Td>
|
||||||
<Td>
|
<Td>{!t.isCanonical && <span className="text-[0.72rem] text-gray-400">will normalize</span>}</Td>
|
||||||
{!t.isCanonical && (
|
|
||||||
<span className="text-[0.72rem] text-gray-400">
|
|
||||||
will normalize
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Td>
|
|
||||||
</tr>
|
</tr>
|
||||||
)),
|
)),
|
||||||
)}
|
)}
|
||||||
@@ -220,7 +216,7 @@ function TitleHarmonization({ titles, onNormalize }: {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 mt-2">
|
<div className="flex items-center gap-3 mt-2">
|
||||||
<Button size="sm" onClick={handleNormalizeAll} disabled={normalizing}>
|
<Button size="sm" onClick={handleNormalizeAll} disabled={normalizing}>
|
||||||
{normalizing ? 'Normalizing...' : 'Normalize All'}
|
{normalizing ? "Normalizing..." : "Normalize All"}
|
||||||
</Button>
|
</Button>
|
||||||
{result && <span className="text-sm text-gray-600">{result}</span>}
|
{result && <span className="text-sm text-gray-600">{result}</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -240,13 +236,19 @@ export function SubtitleListPage() {
|
|||||||
|
|
||||||
const loadSummary = () => {
|
const loadSummary = () => {
|
||||||
if (!summaryCache) setLoading(true);
|
if (!summaryCache) setLoading(true);
|
||||||
api.get<SummaryData>('/api/subtitles/summary')
|
api
|
||||||
.then((d) => { summaryCache = d; setSummary(d); })
|
.get<SummaryData>("/api/subtitles/summary")
|
||||||
|
.then((d) => {
|
||||||
|
summaryCache = d;
|
||||||
|
setSummary(d);
|
||||||
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { loadSummary(); }, []);
|
useEffect(() => {
|
||||||
|
loadSummary();
|
||||||
|
}, [loadSummary]);
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
summaryCache = null;
|
summaryCache = null;
|
||||||
@@ -264,19 +266,20 @@ export function SubtitleListPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold mb-4">Subtitle Manager</h1>
|
<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 ? (
|
{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>
|
<span className="text-sm text-gray-500">No extracted subtitle files yet. Extract subtitles first.</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LanguageSummary
|
<LanguageSummary categories={summary.categories} keepLanguages={summary.keepLanguages} onDelete={refresh} />
|
||||||
categories={summary.categories}
|
|
||||||
keepLanguages={summary.keepLanguages}
|
|
||||||
onDelete={refresh}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TitleHarmonization titles={summary.titles} onNormalize={refresh} />
|
<TitleHarmonization titles={summary.titles} onNormalize={refresh} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@layer base {
|
@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 "./index.css";
|
||||||
import { StrictMode } from 'react';
|
import { createRouter, RouterProvider } from "@tanstack/react-router";
|
||||||
import { createRoot } from 'react-dom/client';
|
import { StrictMode } from "react";
|
||||||
import { RouterProvider, createRouter } from '@tanstack/react-router';
|
import { createRoot } from "react-dom/client";
|
||||||
import { routeTree } from './routeTree.gen';
|
import { routeTree } from "./routeTree.gen";
|
||||||
|
|
||||||
const router = createRouter({ routeTree, defaultPreload: 'intent' });
|
const router = createRouter({ routeTree, defaultPreload: "intent" });
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module "@tanstack/react-router" {
|
||||||
interface Register { router: typeof router; }
|
interface Register {
|
||||||
|
router: typeof router;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = document.getElementById('root');
|
const root = document.getElementById("root");
|
||||||
if (!root) throw new Error('No #root element found');
|
if (!root) throw new Error("No #root element found");
|
||||||
|
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createRootRoute, Link, Outlet } from '@tanstack/react-router';
|
import { createRootRoute, Link, Outlet } from "@tanstack/react-router";
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import { cn } from '~/shared/lib/utils';
|
import { api } from "~/shared/lib/api";
|
||||||
import { api } from '~/shared/lib/api';
|
import { cn } from "~/shared/lib/utils";
|
||||||
|
|
||||||
declare const __APP_VERSION__: string;
|
declare const __APP_VERSION__: string;
|
||||||
|
|
||||||
@@ -13,8 +13,10 @@ function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={to}
|
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')}
|
className={cn(
|
||||||
activeProps={{ className: 'bg-gray-100 text-gray-900 font-medium' }}
|
"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 }}
|
activeOptions={{ exact: true }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -24,13 +26,25 @@ function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
|
|||||||
|
|
||||||
function VersionBadge() {
|
function VersionBadge() {
|
||||||
const [serverVersion, setServerVersion] = useState<string | null>(null);
|
const [serverVersion, setServerVersion] = useState<string | null>(null);
|
||||||
useEffect(() => { api.get<{ version: string }>('/api/version').then((d) => setServerVersion(d.version)).catch(() => {}); }, []);
|
useEffect(() => {
|
||||||
const buildVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : null;
|
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;
|
const mismatch = buildVersion && serverVersion && buildVersion !== serverVersion;
|
||||||
return (
|
return (
|
||||||
<span className="text-[0.65rem] text-gray-400 font-mono ml-1" title={mismatch ? `Frontend: ${buildVersion}, Server: ${serverVersion}` : undefined}>
|
<span
|
||||||
v{serverVersion ?? buildVersion ?? '?'}
|
className="text-[0.65rem] text-gray-400 font-mono ml-1"
|
||||||
{mismatch && <span className="text-amber-500 ml-1" title="Frontend and server versions differ — rebuild or refresh">⚠</span>}
|
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>
|
</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 { createFileRoute } from "@tanstack/react-router";
|
||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
import { ExecutePage } from '~/features/execute/ExecutePage';
|
import { ExecutePage } from "~/features/execute/ExecutePage";
|
||||||
|
|
||||||
export const Route = createFileRoute('/execute')({
|
export const Route = createFileRoute("/execute")({
|
||||||
validateSearch: z.object({
|
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,
|
component: ExecutePage,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { DashboardPage } from '~/features/dashboard/DashboardPage';
|
import { DashboardPage } from "~/features/dashboard/DashboardPage";
|
||||||
|
|
||||||
export const Route = createFileRoute('/')({
|
export const Route = createFileRoute("/")({
|
||||||
component: DashboardPage,
|
component: DashboardPage,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { PathsPage } from '~/features/paths/PathsPage';
|
import { PathsPage } from "~/features/paths/PathsPage";
|
||||||
|
|
||||||
export const Route = createFileRoute('/paths')({
|
export const Route = createFileRoute("/paths")({
|
||||||
component: PathsPage,
|
component: PathsPage,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { PipelinePage } from '~/features/pipeline/PipelinePage';
|
import { PipelinePage } from "~/features/pipeline/PipelinePage";
|
||||||
|
|
||||||
export const Route = createFileRoute('/pipeline')({
|
export const Route = createFileRoute("/pipeline")({
|
||||||
component: PipelinePage,
|
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 />,
|
component: () => <Outlet />,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { AudioDetailPage } from '~/features/review/AudioDetailPage';
|
import { AudioDetailPage } from "~/features/review/AudioDetailPage";
|
||||||
|
|
||||||
export const Route = createFileRoute('/review/audio/$id')({
|
export const Route = createFileRoute("/review/audio/$id")({
|
||||||
component: AudioDetailPage,
|
component: AudioDetailPage,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
import { AudioListPage } from '~/features/review/AudioListPage';
|
import { AudioListPage } from "~/features/review/AudioListPage";
|
||||||
|
|
||||||
export const Route = createFileRoute('/review/audio/')({
|
export const Route = createFileRoute("/review/audio/")({
|
||||||
validateSearch: z.object({
|
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,
|
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/')({
|
export const Route = createFileRoute("/review/")({
|
||||||
beforeLoad: () => { throw redirect({ to: '/review/audio' }); },
|
beforeLoad: () => {
|
||||||
|
throw redirect({ to: "/review/audio" });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { SubtitleDetailPage } from '~/features/subtitles/SubtitleDetailPage';
|
import { SubtitleDetailPage } from "~/features/subtitles/SubtitleDetailPage";
|
||||||
|
|
||||||
export const Route = createFileRoute('/review/subtitles/$id')({
|
export const Route = createFileRoute("/review/subtitles/$id")({
|
||||||
component: SubtitleDetailPage,
|
component: SubtitleDetailPage,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
import { SubtitleExtractPage } from '~/features/subtitles/SubtitleExtractPage';
|
import { SubtitleExtractPage } from "~/features/subtitles/SubtitleExtractPage";
|
||||||
|
|
||||||
export const Route = createFileRoute('/review/subtitles/extract')({
|
export const Route = createFileRoute("/review/subtitles/extract")({
|
||||||
validateSearch: z.object({
|
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,
|
component: SubtitleExtractPage,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { SubtitleListPage } from '~/features/subtitles/SubtitleListPage';
|
import { SubtitleListPage } from "~/features/subtitles/SubtitleListPage";
|
||||||
|
|
||||||
export const Route = createFileRoute('/review/subtitles/')({
|
export const Route = createFileRoute("/review/subtitles/")({
|
||||||
component: SubtitleListPage,
|
component: SubtitleListPage,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { ScanPage } from '~/features/scan/ScanPage';
|
import { ScanPage } from "~/features/scan/ScanPage";
|
||||||
|
|
||||||
export const Route = createFileRoute('/scan')({
|
export const Route = createFileRoute("/scan")({
|
||||||
component: ScanPage,
|
component: ScanPage,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { SetupPage } from '~/features/setup/SetupPage';
|
import { SetupPage } from "~/features/setup/SetupPage";
|
||||||
|
|
||||||
export const Route = createFileRoute('/settings')({
|
export const Route = createFileRoute("/settings")({
|
||||||
component: SetupPage,
|
component: SetupPage,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import type React from 'react';
|
import type React from "react";
|
||||||
import { cn } from '~/shared/lib/utils';
|
import { cn } from "~/shared/lib/utils";
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
info: 'bg-cyan-50 text-cyan-800 border border-cyan-200',
|
info: "bg-cyan-50 text-cyan-800 border border-cyan-200",
|
||||||
warning: 'bg-amber-50 text-amber-800 border border-amber-200',
|
warning: "bg-amber-50 text-amber-800 border border-amber-200",
|
||||||
error: 'bg-red-50 text-red-800 border border-red-200',
|
error: "bg-red-50 text-red-800 border border-red-200",
|
||||||
success: 'bg-green-50 text-green-800 border border-green-200',
|
success: "bg-green-50 text-green-800 border border-green-200",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
|
interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
variant?: keyof typeof variants;
|
variant?: keyof typeof variants;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Alert({ variant = 'info', className, children, ...props }: AlertProps) {
|
export function Alert({ variant = "info", className, children, ...props }: AlertProps) {
|
||||||
return (
|
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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
import { cn } from '~/shared/lib/utils';
|
import { cn } from "~/shared/lib/utils";
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
default: 'bg-gray-100 text-gray-600',
|
default: "bg-gray-100 text-gray-600",
|
||||||
keep: 'bg-green-100 text-green-800',
|
keep: "bg-green-100 text-green-800",
|
||||||
remove: 'bg-red-100 text-red-800',
|
remove: "bg-red-100 text-red-800",
|
||||||
pending: 'bg-gray-200 text-gray-600',
|
pending: "bg-gray-200 text-gray-600",
|
||||||
approved: 'bg-green-100 text-green-800',
|
approved: "bg-green-100 text-green-800",
|
||||||
skipped: 'bg-gray-200 text-gray-600',
|
skipped: "bg-gray-200 text-gray-600",
|
||||||
done: 'bg-cyan-100 text-cyan-800',
|
done: "bg-cyan-100 text-cyan-800",
|
||||||
error: 'bg-red-100 text-red-800',
|
error: "bg-red-100 text-red-800",
|
||||||
noop: 'bg-gray-200 text-gray-600',
|
noop: "bg-gray-200 text-gray-600",
|
||||||
running: 'bg-amber-100 text-amber-800',
|
running: "bg-amber-100 text-amber-800",
|
||||||
manual: 'bg-orange-100 text-orange-800',
|
manual: "bg-orange-100 text-orange-800",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
|
interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||||
variant?: keyof typeof variants;
|
variant?: keyof typeof variants;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Badge({ variant = 'default', className, children, ...props }: BadgeProps) {
|
export function Badge({ variant = "default", className, children, ...props }: BadgeProps) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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],
|
variants[variant],
|
||||||
className,
|
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 type React from "react";
|
||||||
import { cn } from '~/shared/lib/utils';
|
import { cn } from "~/shared/lib/utils";
|
||||||
|
|
||||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: 'primary' | 'secondary' | 'danger';
|
variant?: "primary" | "secondary" | "danger";
|
||||||
size?: 'default' | 'sm' | 'xs';
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center justify-center gap-1 rounded font-medium cursor-pointer transition-colors border-0',
|
"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 === "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 === "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',
|
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 === "default" && "px-3 py-1.5 text-sm",
|
||||||
size === 'sm' && 'px-2.5 py-1 text-xs',
|
size === "sm" && "px-2.5 py-1 text-xs",
|
||||||
size === 'xs' && 'px-2 py-0.5 text-xs',
|
size === "xs" && "px-2 py-0.5 text-xs",
|
||||||
props.disabled && 'opacity-50 cursor-not-allowed',
|
props.disabled && "opacity-50 cursor-not-allowed",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -11,20 +11,20 @@ interface FilterTabsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ACTIVE_COLORS: Record<string, string> = {
|
const ACTIVE_COLORS: Record<string, string> = {
|
||||||
all: 'bg-blue-600 border-blue-600',
|
all: "bg-blue-600 border-blue-600",
|
||||||
pending: 'bg-gray-500 border-gray-500',
|
pending: "bg-gray-500 border-gray-500",
|
||||||
needs_action: 'bg-gray-500 border-gray-500',
|
needs_action: "bg-gray-500 border-gray-500",
|
||||||
noop: 'bg-gray-500 border-gray-500',
|
noop: "bg-gray-500 border-gray-500",
|
||||||
not_extracted: 'bg-gray-500 border-gray-500',
|
not_extracted: "bg-gray-500 border-gray-500",
|
||||||
no_subs: 'bg-gray-500 border-gray-500',
|
no_subs: "bg-gray-500 border-gray-500",
|
||||||
skipped: 'bg-gray-500 border-gray-500',
|
skipped: "bg-gray-500 border-gray-500",
|
||||||
running: 'bg-amber-500 border-amber-500',
|
running: "bg-amber-500 border-amber-500",
|
||||||
done: 'bg-green-600 border-green-600',
|
done: "bg-green-600 border-green-600",
|
||||||
approved: 'bg-green-600 border-green-600',
|
approved: "bg-green-600 border-green-600",
|
||||||
extracted: 'bg-green-600 border-green-600',
|
extracted: "bg-green-600 border-green-600",
|
||||||
keep: 'bg-green-600 border-green-600',
|
keep: "bg-green-600 border-green-600",
|
||||||
error: 'bg-red-600 border-red-600',
|
error: "bg-red-600 border-red-600",
|
||||||
manual: 'bg-orange-500 border-orange-500',
|
manual: "bg-orange-500 border-orange-500",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FilterTabs({ tabs, filter, totalCounts, onFilterChange }: FilterTabsProps) {
|
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">
|
<div className="flex gap-1 flex-wrap mb-3 items-center">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = filter === tab.key;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onFilterChange(tab.key)}
|
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}
|
{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>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type React from 'react';
|
import type React from "react";
|
||||||
import { cn } from '~/shared/lib/utils';
|
import { cn } from "~/shared/lib/utils";
|
||||||
|
|
||||||
export function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
|
export function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
className={cn(
|
className={cn(
|
||||||
'border border-gray-300 rounded px-2.5 py-1.5 text-sm bg-white w-full',
|
"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',
|
"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',
|
"disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type React from 'react';
|
import type React from "react";
|
||||||
import { cn } from '~/shared/lib/utils';
|
import { cn } from "~/shared/lib/utils";
|
||||||
|
|
||||||
export function Select({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
|
export function Select({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
className={cn(
|
className={cn(
|
||||||
'border border-gray-300 rounded px-2 py-1.5 text-sm bg-white',
|
"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',
|
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent",
|
||||||
'disabled:bg-gray-100 disabled:cursor-not-allowed',
|
"disabled:bg-gray-100 disabled:cursor-not-allowed",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type React from 'react';
|
import type React from "react";
|
||||||
import { cn } from '~/shared/lib/utils';
|
import { cn } from "~/shared/lib/utils";
|
||||||
|
|
||||||
export function Textarea({ className, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
export function Textarea({ className, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
'border border-gray-300 rounded px-2.5 py-1.5 text-sm bg-white w-full resize-vertical',
|
"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',
|
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/** Base URL for API calls. In dev Vite proxies /api → :3000. */
|
/** 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> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(BASE + path, {
|
const res = await fetch(BASE + path, {
|
||||||
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
|
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
||||||
...init,
|
...init,
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -16,11 +16,10 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
export const api = {
|
export const api = {
|
||||||
get: <T>(path: string) => request<T>(path),
|
get: <T>(path: string) => request<T>(path),
|
||||||
post: <T>(path: string, body?: unknown) =>
|
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) =>
|
patch: <T>(path: string, body?: unknown) =>
|
||||||
request<T>(path, { method: 'PATCH', body: body !== undefined ? JSON.stringify(body) : undefined }),
|
request<T>(path, { method: "PATCH", body: body !== undefined ? JSON.stringify(body) : undefined }),
|
||||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||||
/** POST multipart/form-data (file upload). Omit Content-Type so browser sets boundary. */
|
/** POST multipart/form-data (file upload). Omit Content-Type so browser sets boundary. */
|
||||||
postForm: <T>(path: string, body: FormData) =>
|
postForm: <T>(path: string, body: FormData) => request<T>(path, { method: "POST", body, headers: {} }),
|
||||||
request<T>(path, { method: 'POST', body, headers: {} }),
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,53 @@
|
|||||||
export const LANG_NAMES: Record<string, string> = {
|
export const LANG_NAMES: Record<string, string> = {
|
||||||
eng: 'English', deu: 'German', spa: 'Spanish', fra: 'French', ita: 'Italian',
|
eng: "English",
|
||||||
por: 'Portuguese', jpn: 'Japanese', kor: 'Korean', zho: 'Chinese', ara: 'Arabic',
|
deu: "German",
|
||||||
rus: 'Russian', nld: 'Dutch', swe: 'Swedish', nor: 'Norwegian', dan: 'Danish',
|
spa: "Spanish",
|
||||||
fin: 'Finnish', pol: 'Polish', tur: 'Turkish', tha: 'Thai', hin: 'Hindi',
|
fra: "French",
|
||||||
hun: 'Hungarian', ces: 'Czech', ron: 'Romanian', ell: 'Greek', heb: 'Hebrew',
|
ita: "Italian",
|
||||||
fas: 'Persian', ukr: 'Ukrainian', ind: 'Indonesian', msa: 'Malay', vie: 'Vietnamese',
|
por: "Portuguese",
|
||||||
cat: 'Catalan', tam: 'Tamil', tel: 'Telugu', slk: 'Slovak', hrv: 'Croatian',
|
jpn: "Japanese",
|
||||||
bul: 'Bulgarian', srp: 'Serbian', slv: 'Slovenian', lav: 'Latvian', lit: 'Lithuanian',
|
kor: "Korean",
|
||||||
est: 'Estonian', isl: 'Icelandic', nob: 'Norwegian Bokmål', nno: 'Norwegian Nynorsk',
|
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 const KNOWN_LANG_NAMES = new Set(Object.values(LANG_NAMES).map((n) => n.toLowerCase()));
|
||||||
|
|
||||||
export function langName(code: string | null | undefined): string {
|
export function langName(code: string | null | undefined): string {
|
||||||
if (!code) return '—';
|
if (!code) return "—";
|
||||||
return LANG_NAMES[code.toLowerCase()] ?? code.toUpperCase();
|
return LANG_NAMES[code.toLowerCase()] ?? code.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
export interface MediaItem {
|
export interface MediaItem {
|
||||||
id: number;
|
id: number;
|
||||||
jellyfin_id: string;
|
jellyfin_id: string;
|
||||||
type: 'Movie' | 'Episode';
|
type: "Movie" | "Episode";
|
||||||
name: string;
|
name: string;
|
||||||
series_name: string | null;
|
series_name: string | null;
|
||||||
series_jellyfin_id: string | null;
|
series_jellyfin_id: string | null;
|
||||||
@@ -46,9 +46,9 @@ export interface ReviewPlan {
|
|||||||
item_id: number;
|
item_id: number;
|
||||||
status: string;
|
status: string;
|
||||||
is_noop: number;
|
is_noop: number;
|
||||||
confidence: 'high' | 'low';
|
confidence: "high" | "low";
|
||||||
apple_compat: 'direct_play' | 'remux' | 'audio_transcode' | null;
|
apple_compat: "direct_play" | "remux" | "audio_transcode" | null;
|
||||||
job_type: 'copy' | 'transcode';
|
job_type: "copy" | "transcode";
|
||||||
subs_extracted: number;
|
subs_extracted: number;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
reviewed_at: string | null;
|
reviewed_at: string | null;
|
||||||
@@ -71,7 +71,7 @@ export interface StreamDecision {
|
|||||||
id: number;
|
id: number;
|
||||||
plan_id: number;
|
plan_id: number;
|
||||||
stream_id: number;
|
stream_id: number;
|
||||||
action: 'keep' | 'remove';
|
action: "keep" | "remove";
|
||||||
target_index: number | null;
|
target_index: number | null;
|
||||||
custom_title: string | null;
|
custom_title: string | null;
|
||||||
transcode_codec: string | null;
|
transcode_codec: string | null;
|
||||||
@@ -81,8 +81,8 @@ export interface Job {
|
|||||||
id: number;
|
id: number;
|
||||||
item_id: number;
|
item_id: number;
|
||||||
command: string;
|
command: string;
|
||||||
job_type: 'copy' | 'transcode';
|
job_type: "copy" | "transcode";
|
||||||
status: 'pending' | 'running' | 'done' | 'error';
|
status: "pending" | "running" | "done" | "error";
|
||||||
output: string | null;
|
output: string | null;
|
||||||
exit_code: number | null;
|
exit_code: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { clsx, type ClassValue } from 'clsx';
|
import { type ClassValue, clsx } from "clsx";
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
|
|||||||
@@ -1,32 +1,28 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { resolve } from "node:path";
|
||||||
import react from '@vitejs/plugin-react-swc';
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
|
||||||
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
|
import react from "@vitejs/plugin-react-swc";
|
||||||
import { resolve } from 'node:path';
|
import { defineConfig } from "vite";
|
||||||
import pkg from './package.json' with { type: 'json' };
|
import pkg from "./package.json" with { type: "json" };
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [TanStackRouterVite({ target: "react", autoCodeSplitting: true }), react(), tailwindcss()],
|
||||||
TanStackRouterVite({ target: 'react', autoCodeSplitting: true }),
|
|
||||||
react(),
|
|
||||||
tailwindcss(),
|
|
||||||
],
|
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'~': resolve(__dirname, 'src'),
|
"~": resolve(__dirname, "src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': { target: 'http://localhost:3000', changeOrigin: true },
|
"/api": { target: "http://localhost:3000", changeOrigin: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
define: {
|
define: {
|
||||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: "dist",
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user