fix analyzer + api boundary + perf + scheduler hardening

- analyzer: rewrite checkAudioOrderChanged to compare actual output order, unify assignTargetOrder with a shared sortKeptStreams util in ffmpeg builder
- review: recompute is_noop via full audio removed/reordered/transcode/subs check on toggle, preserve custom_title across rescan by matching (type,lang,stream_index,title), batch pipeline transcode-reasons query to avoid N+1
- validate: add lib/validate.ts with parseId + isOneOf helpers; replace bare Number(c.req.param('id')) with 400 on invalid ids across review/subtitles
- scan: atomic CAS on scan_running config to prevent concurrent scans
- subtitles: path-traversal guard — only unlink sidecars within the media item's directory; log-and-orphan DB entries pointing outside
- schedule: include end minute in window (<= vs <)
- db: add indexes on review_plans(status,is_noop), stream_decisions(plan_id), media_items(series_jellyfin_id,series_name,type), media_streams(item_id,type), subtitle_files(item_id), jobs(status,item_id)
This commit is contained in:
2026-04-13 07:31:48 +02:00
parent cdcb1ff706
commit 93ed0ac33c
12 changed files with 2210 additions and 173 deletions

View File

@@ -1,8 +1,9 @@
import { Hono } from 'hono';
import { getDb, getConfig, getAllConfig } from '../db/index';
import { analyzeItem } from '../services/analyzer';
import { analyzeItem, assignTargetOrder } from '../services/analyzer';
import { buildCommand } from '../services/ffmpeg';
import { normalizeLanguage, getItem, refreshItem, mapStream } from '../services/jellyfin';
import { parseId, isOneOf } from '../lib/validate';
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '../types';
const app = new Hono();
@@ -62,7 +63,16 @@ function loadItemDetail(db: ReturnType<typeof getDb>, itemId: number) {
return { item, streams, plan: plan ?? null, decisions, command };
}
function reanalyze(db: ReturnType<typeof getDb>, itemId: number): void {
/**
* Match old custom_titles to new stream IDs after rescan. Keys by a
* composite of (type, language, stream_index, title) so user overrides
* survive stream-id changes when Jellyfin re-probes metadata.
*/
function titleKey(s: { type: string; language: string | null; stream_index: number; title: string | null }): string {
return `${s.type}|${s.language ?? ''}|${s.stream_index}|${s.title ?? ''}`;
}
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;
if (!item) return;
@@ -78,17 +88,69 @@ function reanalyze(db: ReturnType<typeof getDb>, itemId: number): void {
`).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 existingTitles = new Map<number, string | null>(
// Preserve existing custom_titles: prefer by stream_id (streams unchanged);
// fall back to titleKey match (streams regenerated after rescan).
const byStreamId = new Map<number, string | null>(
(db.prepare('SELECT stream_id, custom_title FROM stream_decisions WHERE plan_id = ?').all(plan.id) as { stream_id: number; custom_title: string | null }[])
.map((r) => [r.stream_id, r.custom_title])
);
const streamById = new Map(streams.map(s => [s.id, s] as const));
db.prepare('DELETE FROM stream_decisions WHERE plan_id = ?').run(plan.id);
const insertDecision = db.prepare('INSERT INTO stream_decisions (plan_id, stream_id, action, target_index, custom_title, transcode_codec) VALUES (?, ?, ?, ?, ?, ?)');
for (const dec of analysis.decisions) {
db.prepare('INSERT INTO stream_decisions (plan_id, stream_id, action, target_index, custom_title, transcode_codec) VALUES (?, ?, ?, ?, ?, ?)')
.run(plan.id, dec.stream_id, dec.action, dec.target_index, existingTitles.get(dec.stream_id) ?? null, dec.transcode_codec);
let customTitle = byStreamId.get(dec.stream_id) ?? null;
if (!customTitle && preservedTitles) {
const s = streamById.get(dec.stream_id);
if (s) customTitle = preservedTitles.get(titleKey(s)) ?? null;
}
insertDecision.run(plan.id, dec.stream_id, dec.action, dec.target_index, customTitle, dec.transcode_codec);
}
}
/**
* After the user toggles a stream action, re-run assignTargetOrder and
* recompute is_noop without wiping user-chosen actions or custom_titles.
*/
function recomputePlanAfterToggle(db: ReturnType<typeof getDb>, itemId: number): void {
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(itemId) as MediaItem | undefined;
if (!item) return;
const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(itemId) as MediaStream[];
const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number } | undefined;
if (!plan) return;
const decisions = db.prepare('SELECT stream_id, action, target_index, transcode_codec FROM stream_decisions WHERE plan_id = ?').all(plan.id) as {
stream_id: number; action: 'keep' | 'remove'; target_index: number | null; transcode_codec: string | null
}[];
const origLang = item.original_language ? normalizeLanguage(item.original_language) : null;
const audioLanguages: string[] = JSON.parse(getConfig('audio_languages') ?? '[]');
// Re-assign target_index based on current actions
const decWithIdx = decisions.map(d => ({ stream_id: d.stream_id, action: d.action, target_index: null as number | null, transcode_codec: d.transcode_codec }));
assignTargetOrder(streams, decWithIdx, origLang, audioLanguages);
const updateIdx = db.prepare('UPDATE stream_decisions SET target_index = ? WHERE plan_id = ? AND stream_id = ?');
for (const d of decWithIdx) updateIdx.run(d.target_index, plan.id, d.stream_id);
// Recompute is_noop: audio removed OR reordered OR subs exist OR transcode needed
const anyAudioRemoved = streams.some(s => s.type === 'Audio' && decWithIdx.find(d => d.stream_id === s.id)?.action === 'remove');
const hasSubs = streams.some(s => s.type === 'Subtitle');
const needsTranscode = decWithIdx.some(d => d.transcode_codec != null && d.action === 'keep');
const keptAudio = streams
.filter(s => s.type === 'Audio' && decWithIdx.find(d => d.stream_id === s.id)?.action === 'keep')
.sort((a, b) => a.stream_index - b.stream_index);
let audioOrderChanged = false;
for (let i = 0; i < keptAudio.length; i++) {
const dec = decWithIdx.find(d => d.stream_id === keptAudio[i].id);
if (dec?.target_index !== i) { audioOrderChanged = true; break; }
}
const isNoop = !anyAudioRemoved && !audioOrderChanged && !hasSubs && !needsTranscode;
db.prepare('UPDATE review_plans SET is_noop = ? WHERE id = ?').run(isNoop ? 1 : 0, plan.id);
}
// ─── Pipeline: summary ───────────────────────────────────────────────────────
app.get('/pipeline', (c) => {
@@ -141,16 +203,24 @@ app.get('/pipeline', (c) => {
const noops = db.prepare('SELECT COUNT(*) as count FROM review_plans WHERE is_noop = 1').get() as { count: number };
// Add transcode reasons per review plan
const transcodeStmt = db.prepare(`
SELECT DISTINCT ms.codec, sd.transcode_codec
FROM stream_decisions sd
JOIN media_streams ms ON ms.id = sd.stream_id
WHERE sd.plan_id = ? AND sd.transcode_codec IS NOT NULL
`);
for (const item of review as any[]) {
const rows = transcodeStmt.all(item.id) as { codec: string; transcode_codec: string }[];
item.transcode_reasons = rows.map(r => `${(r.codec ?? '').toUpperCase()}${r.transcode_codec.toUpperCase()}`);
// Batch transcode reasons for all review plans in one query (avoids N+1)
const planIds = (review as { id: number }[]).map(r => r.id);
const reasonsByPlan = new Map<number, string[]>();
if (planIds.length > 0) {
const placeholders = planIds.map(() => '?').join(',');
const allReasons = db.prepare(`
SELECT DISTINCT sd.plan_id, ms.codec, sd.transcode_codec
FROM stream_decisions sd
JOIN media_streams ms ON ms.id = sd.stream_id
WHERE sd.plan_id IN (${placeholders}) AND sd.transcode_codec IS NOT NULL
`).all(...planIds) as { plan_id: number; codec: string | null; transcode_codec: string }[];
for (const r of allReasons) {
if (!reasonsByPlan.has(r.plan_id)) reasonsByPlan.set(r.plan_id, []);
reasonsByPlan.get(r.plan_id)!.push(`${(r.codec ?? '').toUpperCase()}${r.transcode_codec.toUpperCase()}`);
}
}
for (const item of review as { id: number; transcode_reasons?: string[] }[]) {
item.transcode_reasons = reasonsByPlan.get(item.id) ?? [];
}
return c.json({ review, queued, processing, done, noopCount: noops.count, jellyfinUrl });
@@ -260,7 +330,8 @@ app.post('/series/:seriesKey/approve-all', (c) => {
app.post('/season/:seriesKey/:season/approve-all', (c) => {
const db = getDb();
const seriesKey = decodeURIComponent(c.req.param('seriesKey'));
const season = Number(c.req.param('season'));
const season = Number.parseInt(c.req.param('season') ?? '', 10);
if (!Number.isFinite(season)) return c.json({ error: 'invalid season' }, 400);
const pending = db.prepare(`
SELECT rp.*, mi.id as item_id FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id
WHERE mi.type = 'Episode' AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?))
@@ -293,7 +364,8 @@ app.post('/approve-all', (c) => {
app.get('/:id', (c) => {
const db = getDb();
const id = Number(c.req.param('id'));
const id = parseId(c.req.param('id'));
if (id == null) return c.json({ error: 'invalid id' }, 400);
const detail = loadItemDetail(db, id);
if (!detail.item) return c.notFound();
return c.json(detail);
@@ -303,7 +375,8 @@ app.get('/:id', (c) => {
app.patch('/:id/language', async (c) => {
const db = getDb();
const id = Number(c.req.param('id'));
const id = parseId(c.req.param('id'));
if (id == null) return c.json({ error: 'invalid id' }, 400);
const body = await c.req.json<{ language: string | null }>();
const lang = body.language || null;
db.prepare("UPDATE media_items SET original_language = ?, orig_lang_source = 'manual', needs_review = 0 WHERE id = ?")
@@ -318,8 +391,9 @@ app.patch('/:id/language', async (c) => {
app.patch('/:id/stream/:streamId/title', async (c) => {
const db = getDb();
const itemId = Number(c.req.param('id'));
const streamId = Number(c.req.param('streamId'));
const itemId = parseId(c.req.param('id'));
const streamId = parseId(c.req.param('streamId'));
if (itemId == null || streamId == null) return c.json({ error: 'invalid id' }, 400);
const body = await c.req.json<{ title: string }>();
const title = (body.title ?? '').trim() || null;
const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number } | undefined;
@@ -334,28 +408,26 @@ app.patch('/:id/stream/:streamId/title', async (c) => {
app.patch('/:id/stream/:streamId', async (c) => {
const db = getDb();
const itemId = Number(c.req.param('id'));
const streamId = Number(c.req.param('streamId'));
const body = await c.req.json<{ action: 'keep' | 'remove' }>();
const action = body.action;
const itemId = parseId(c.req.param('id'));
const streamId = parseId(c.req.param('streamId'));
if (itemId == null || streamId == null) return c.json({ error: 'invalid id' }, 400);
const body = await c.req.json<{ action: unknown }>().catch(() => ({ action: null }));
if (!isOneOf(body.action, ['keep', 'remove'] as const)) {
return c.json({ error: 'action must be "keep" or "remove"' }, 400);
}
const action: 'keep' | 'remove' = body.action;
// Only audio streams can be toggled — subtitles are always removed (extracted to sidecar)
const stream = db.prepare('SELECT type FROM media_streams WHERE id = ?').get(streamId) as { type: string } | undefined;
if (stream?.type === 'Subtitle') return c.json({ error: 'Subtitle streams cannot be toggled' }, 400);
const stream = db.prepare('SELECT type, item_id FROM media_streams WHERE id = ?').get(streamId) as { type: string; item_id: number } | undefined;
if (!stream || stream.item_id !== itemId) return c.json({ error: 'stream not found on item' }, 404);
if (stream.type === 'Subtitle') return c.json({ error: 'Subtitle streams cannot be toggled' }, 400);
const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number } | undefined;
if (!plan) return c.notFound();
db.prepare('UPDATE stream_decisions SET action = ? WHERE plan_id = ? AND stream_id = ?').run(action, plan.id, streamId);
// is_noop only considers audio streams (subtitle removal is implicit)
const audioNotKept = (db.prepare(`
SELECT COUNT(*) as n FROM stream_decisions sd
JOIN media_streams ms ON ms.id = sd.stream_id
WHERE sd.plan_id = ? AND ms.type = 'Audio' AND sd.action != 'keep'
`).get(plan.id) as { n: number }).n;
// Also check audio ordering
const isNoop = audioNotKept === 0; // simplified — full recheck would need analyzer
db.prepare('UPDATE review_plans SET is_noop = ? WHERE id = ?').run(isNoop ? 1 : 0, plan.id);
recomputePlanAfterToggle(db, itemId);
const detail = loadItemDetail(db, itemId);
if (!detail.item) return c.notFound();
@@ -366,7 +438,8 @@ app.patch('/:id/stream/:streamId', async (c) => {
app.post('/:id/approve', (c) => {
const db = getDb();
const id = Number(c.req.param('id'));
const id = parseId(c.req.param('id'));
if (id == null) return c.json({ error: 'invalid id' }, 400);
const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(id) as ReviewPlan | undefined;
if (!plan) return c.notFound();
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
@@ -381,7 +454,8 @@ app.post('/:id/approve', (c) => {
app.post('/:id/unapprove', (c) => {
const db = getDb();
const id = Number(c.req.param('id'));
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);
@@ -398,14 +472,16 @@ app.post('/:id/unapprove', (c) => {
app.post('/:id/skip', (c) => {
const db = getDb();
const id = Number(c.req.param('id'));
const id = parseId(c.req.param('id'));
if (id == null) return c.json({ error: 'invalid id' }, 400);
db.prepare("UPDATE review_plans SET status = 'skipped', reviewed_at = datetime('now') WHERE item_id = ?").run(id);
return c.json({ ok: true });
});
app.post('/:id/unskip', (c) => {
const db = getDb();
const id = Number(c.req.param('id'));
const id = parseId(c.req.param('id'));
if (id == null) return c.json({ error: 'invalid id' }, 400);
db.prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE item_id = ? AND status = 'skipped'").run(id);
return c.json({ ok: true });
});
@@ -414,7 +490,8 @@ app.post('/:id/unskip', (c) => {
app.post('/:id/rescan', async (c) => {
const db = getDb();
const id = Number(c.req.param('id'));
const id = parseId(c.req.param('id'));
if (id == null) return c.json({ error: 'invalid id' }, 400);
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(id) as MediaItem | undefined;
if (!item) return c.notFound();
@@ -425,6 +502,20 @@ app.post('/:id/rescan', async (c) => {
// so the streams we fetch afterwards reflect the current file on disk.
await refreshItem(jfCfg, item.jellyfin_id);
// Snapshot custom_titles keyed by stable properties, since replacing
// media_streams cascades away all stream_decisions.
const preservedTitles = new Map<string, string>();
const oldRows = db.prepare(`
SELECT ms.type, ms.language, ms.stream_index, ms.title, sd.custom_title
FROM stream_decisions sd
JOIN media_streams ms ON ms.id = sd.stream_id
JOIN review_plans rp ON rp.id = sd.plan_id
WHERE rp.item_id = ? AND sd.custom_title IS NOT NULL
`).all(id) as { type: string; language: string | null; stream_index: number; title: string | null; custom_title: string }[];
for (const r of oldRows) {
preservedTitles.set(titleKey(r), r.custom_title);
}
const fresh = await getItem(jfCfg, item.jellyfin_id);
if (fresh) {
const insertStream = db.prepare(`
@@ -440,7 +531,7 @@ app.post('/:id/rescan', async (c) => {
}
}
reanalyze(db, id);
reanalyze(db, id, preservedTitles);
const detail = loadItemDetail(db, id);
if (!detail.item) return c.notFound();
return c.json(detail);
@@ -449,7 +540,8 @@ app.post('/:id/rescan', async (c) => {
// ─── Pipeline: approve up to here ────────────────────────────────────────────
app.post('/approve-up-to/:id', (c) => {
const targetId = Number(c.req.param('id'));
const targetId = parseId(c.req.param('id'));
if (targetId == null) return c.json({ error: 'invalid id' }, 400);
const db = getDb();
const target = db.prepare('SELECT id FROM review_plans WHERE id = ?').get(targetId) as { id: number } | undefined;

View File

@@ -43,16 +43,18 @@ app.get('/', (c) => {
// ─── Start ────────────────────────────────────────────────────────────────────
app.post('/start', async (c) => {
if (getConfig('scan_running') === '1') {
const db = getDb();
// Atomic claim: only succeed if scan_running is not already '1'.
const claim = db.prepare("UPDATE config SET value = '1' WHERE key = 'scan_running' AND value != '1'").run();
if (claim.changes === 0) {
return c.json({ ok: false, error: 'Scan already running' }, 409);
}
const body = await c.req.json<{ limit?: number }>().catch(() => ({}));
const body = await c.req.json<{ limit?: number }>().catch(() => ({ limit: undefined }));
const formLimit = body.limit ?? null;
const envLimit = process.env.SCAN_LIMIT ? Number(process.env.SCAN_LIMIT) : null;
const limit = formLimit ?? envLimit ?? null;
setConfig('scan_limit', limit != null ? String(limit) : '');
setConfig('scan_running', '1');
runScan(limit).catch((err) => {
logError('Scan failed:', err);

View File

@@ -2,8 +2,11 @@ import { Hono } from 'hono';
import { getDb, getConfig, getAllConfig } from '../db/index';
import { buildExtractOnlyCommand } from '../services/ffmpeg';
import { normalizeLanguage, getItem, refreshItem, mapStream } from '../services/jellyfin';
import { parseId } from '../lib/validate';
import type { MediaItem, MediaStream, SubtitleFile, ReviewPlan, StreamDecision } from '../types';
import { unlinkSync } from 'node:fs';
import { dirname, resolve as resolvePath, sep } from 'node:path';
import { error as logError } from '../lib/log';
const app = new Hono();
@@ -245,7 +248,9 @@ app.get('/summary', (c) => {
app.get('/:id', (c) => {
const db = getDb();
const detail = loadDetail(db, Number(c.req.param('id')));
const id = parseId(c.req.param('id'));
if (id == null) return c.json({ error: 'invalid id' }, 400);
const detail = loadDetail(db, id);
if (!detail) return c.notFound();
return c.json(detail);
});
@@ -254,8 +259,9 @@ app.get('/:id', (c) => {
app.patch('/:id/stream/:streamId/language', async (c) => {
const db = getDb();
const itemId = Number(c.req.param('id'));
const streamId = Number(c.req.param('streamId'));
const itemId = parseId(c.req.param('id'));
const streamId = parseId(c.req.param('streamId'));
if (itemId == null || streamId == null) return c.json({ error: 'invalid id' }, 400);
const body = await c.req.json<{ language: string }>();
const lang = (body.language ?? '').trim() || null;
@@ -274,8 +280,9 @@ app.patch('/:id/stream/:streamId/language', async (c) => {
app.patch('/:id/stream/:streamId/title', async (c) => {
const db = getDb();
const itemId = Number(c.req.param('id'));
const streamId = Number(c.req.param('streamId'));
const itemId = parseId(c.req.param('id'));
const streamId = parseId(c.req.param('streamId'));
if (itemId == null || streamId == null) return c.json({ error: 'invalid id' }, 400);
const body = await c.req.json<{ title: string }>();
const title = (body.title ?? '').trim() || null;
@@ -316,7 +323,8 @@ app.post('/extract-all', (c) => {
app.post('/:id/extract', (c) => {
const db = getDb();
const id = Number(c.req.param('id'));
const id = parseId(c.req.param('id'));
if (id == null) return c.json({ error: 'invalid id' }, 400);
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(id) as MediaItem | undefined;
if (!item) return c.notFound();
@@ -334,14 +342,32 @@ app.post('/:id/extract', (c) => {
// ─── Delete file ─────────────────────────────────────────────────────────────
/**
* Verify a sidecar file path lives inside the directory of its owning
* media item. Guards against path-traversal via malformed DB state.
*/
function isSidecarOfItem(filePath: string, videoPath: string): boolean {
const videoDir = resolvePath(dirname(videoPath));
const targetDir = resolvePath(dirname(filePath));
return targetDir === videoDir || targetDir.startsWith(videoDir + sep);
}
app.delete('/:id/files/:fileId', (c) => {
const db = getDb();
const itemId = Number(c.req.param('id'));
const fileId = Number(c.req.param('fileId'));
const itemId = parseId(c.req.param('id'));
const fileId = parseId(c.req.param('fileId'));
if (itemId == null || fileId == null) return c.json({ error: 'invalid id' }, 400);
const file = db.prepare('SELECT * FROM subtitle_files WHERE id = ? AND item_id = ?').get(fileId, itemId) as SubtitleFile | undefined;
if (!file) return c.notFound();
const item = db.prepare('SELECT file_path FROM media_items WHERE id = ?').get(itemId) as { file_path: string } | undefined;
if (!item || !isSidecarOfItem(file.file_path, item.file_path)) {
logError(`Refusing to delete subtitle file outside media dir: ${file.file_path}`);
db.prepare('DELETE FROM subtitle_files WHERE id = ?').run(fileId);
return c.json({ ok: false, error: 'file path outside media directory; DB entry removed without touching disk' }, 400);
}
try { unlinkSync(file.file_path); } catch { /* file may not exist */ }
db.prepare('DELETE FROM subtitle_files WHERE id = ?').run(fileId);
@@ -353,7 +379,8 @@ app.delete('/:id/files/:fileId', (c) => {
app.post('/:id/rescan', async (c) => {
const db = getDb();
const id = Number(c.req.param('id'));
const id = parseId(c.req.param('id'));
if (id == null) return c.json({ error: 'invalid id' }, 400);
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(id) as MediaItem | undefined;
if (!item) return c.notFound();
@@ -407,7 +434,12 @@ app.post('/batch-delete', async (c) => {
}
for (const file of files) {
try { unlinkSync(file.file_path); } catch { /* file may not exist */ }
const item = db.prepare('SELECT file_path FROM media_items WHERE id = ?').get(file.item_id) as { file_path: string } | undefined;
if (item && isSidecarOfItem(file.file_path, item.file_path)) {
try { unlinkSync(file.file_path); } catch { /* file may not exist */ }
} else {
logError(`Refusing to delete subtitle file outside media dir: ${file.file_path}`);
}
db.prepare('DELETE FROM subtitle_files WHERE id = ?').run(file.id);
deleted++;
}