Compare commits

...

12 Commits

Author SHA1 Message Date
7cefd9bf04 wire scan completion to pipeline page
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m50s
After a scan completes, show a "Review in Pipeline →" link next to the
status label. Nav already included the Pipeline entry from a prior task.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 01:53:29 +01:00
3881f3a4c2 bump version to 2026.03.27 for unified pipeline release
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 01:53:07 +01:00
8bdfa79215 add pipeline Kanban board: route, layout, review/queue/processing/done columns, schedule controls
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 01:51:47 +01:00
fd72a6d212 add pipeline API: approve-up-to, series language, pipeline summary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 01:49:14 +01:00
9cffdaac47 fix reanalyze: pass container to analyzeItem, store new pipeline fields 2026-03-27 01:47:40 +01:00
9a19350f7e add job scheduler: sleep between jobs, schedule window, FFmpeg progress parsing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 01:46:41 +01:00
97e60dbfc5 add buildPipelineCommand: single FFmpeg command for sub extraction, audio cleanup, transcode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 01:46:26 +01:00
ecb0732185 store confidence, apple_compat, job_type, transcode_codec during scan 2026-03-27 01:45:56 +01:00
b1cf0fca38 unify analyzer: 3-step pipeline with apple compat, transcode decisions, extended is_noop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 01:44:22 +01:00
c2e5b70b02 add schema migrations for unified pipeline: confidence, apple_compat, job_type, transcode_codec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 01:42:18 +01:00
c017ca09d4 add apple compatibility service: codec checks, transcode target mapping 2026-03-27 01:41:21 +01:00
6507924e45 add .worktrees/ to .gitignore 2026-03-27 01:39:06 +01:00
24 changed files with 1018 additions and 31 deletions

1
.gitignore vendored
View File

@@ -10,3 +10,4 @@ bun.lockb
.env.production
dist/
src/routeTree.gen.ts
.worktrees/

View File

@@ -1,6 +1,6 @@
{
"name": "netfelix-audio-fix",
"version": "2026.03.05.8",
"version": "2026.03.27",
"scripts": {
"dev:server": "NODE_ENV=development bun --hot server/index.tsx",
"dev:client": "vite",

View File

@@ -6,6 +6,7 @@ import type { Job, Node, MediaItem, MediaStream } from '../types';
import { predictExtractedFiles } from '../services/ffmpeg';
import { accessSync, constants } from 'node:fs';
import { log, error as logError } from '../lib/log';
import { getSchedulerState, updateSchedulerState } from '../services/scheduler';
const app = new Hono();
@@ -305,4 +306,28 @@ async function runJob(job: Job): Promise<void> {
}
}
// ─── Scheduler ────────────────────────────────────────────────────────────────
// GET /scheduler — current scheduler state
app.get('/scheduler', (c) => {
return c.json(getSchedulerState());
});
// PATCH /scheduler — update scheduler settings
app.patch('/scheduler', async (c) => {
const body = await c.req.json();
updateSchedulerState(body);
return c.json(getSchedulerState());
});
// ─── FFmpeg progress parsing ───────────────────────────────────────────────────
/** Parse FFmpeg stderr line for progress. Returns seconds processed or null. */
export function parseFFmpegProgress(line: string): number | null {
const match = line.match(/time=(\d+):(\d+):(\d+)\.(\d+)/);
if (!match) return null;
const [, h, m, s] = match.map(Number);
return h * 3600 + m * 60 + s;
}
export default app;

View File

@@ -68,13 +68,14 @@ function reanalyze(db: ReturnType<typeof getDb>, itemId: number): void {
const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(itemId) as MediaStream[];
const subtitleLanguages = getSubtitleLanguages();
const analysis = analyzeItem({ original_language: item.original_language, needs_review: item.needs_review }, streams, { subtitleLanguages });
const audioLanguages: string[] = JSON.parse(getConfig('audio_languages') ?? '[]');
const analysis = analyzeItem({ original_language: item.original_language, needs_review: item.needs_review, container: item.container }, streams, { subtitleLanguages, audioLanguages });
db.prepare(`
INSERT INTO review_plans (item_id, status, is_noop, notes)
VALUES (?, 'pending', ?, ?)
ON CONFLICT(item_id) DO UPDATE SET status = 'pending', is_noop = excluded.is_noop, notes = excluded.notes
`).run(itemId, analysis.is_noop ? 1 : 0, analysis.notes);
INSERT INTO review_plans (item_id, status, is_noop, confidence, apple_compat, job_type, notes)
VALUES (?, 'pending', ?, ?, ?, ?, ?)
ON CONFLICT(item_id) DO UPDATE SET status = 'pending', is_noop = excluded.is_noop, confidence = excluded.confidence, apple_compat = excluded.apple_compat, job_type = excluded.job_type, notes = excluded.notes
`).run(itemId, analysis.is_noop ? 1 : 0, analysis.confidence, analysis.apple_compat, analysis.job_type, analysis.notes.length > 0 ? analysis.notes.join('\n') : null);
const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number };
const existingTitles = new Map<number, string | null>(
@@ -83,8 +84,8 @@ function reanalyze(db: ReturnType<typeof getDb>, itemId: number): void {
);
db.prepare('DELETE FROM stream_decisions WHERE plan_id = ?').run(plan.id);
for (const dec of analysis.decisions) {
db.prepare('INSERT INTO stream_decisions (plan_id, stream_id, action, target_index, custom_title) VALUES (?, ?, ?, ?, ?)')
.run(plan.id, dec.stream_id, dec.action, dec.target_index, existingTitles.get(dec.stream_id) ?? null);
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);
}
}
@@ -378,4 +379,126 @@ app.post('/:id/rescan', async (c) => {
return c.json(detail);
});
// ─── Pipeline: approve up to here ────────────────────────────────────────────
app.post('/approve-up-to/:id', (c) => {
const targetId = Number(c.req.param('id'));
const db = getDb();
const target = db.prepare('SELECT id FROM review_plans WHERE id = ?').get(targetId) as { id: number } | undefined;
if (!target) return c.json({ error: 'Plan not found' }, 404);
// Get all pending plans sorted by confidence (high first), then name
const pendingPlans = db.prepare(`
SELECT rp.id
FROM review_plans rp
JOIN media_items mi ON mi.id = rp.item_id
WHERE rp.status = 'pending' AND rp.is_noop = 0
ORDER BY
CASE rp.confidence WHEN 'high' THEN 0 ELSE 1 END,
COALESCE(mi.series_name, mi.name),
mi.season_number,
mi.episode_number,
mi.name
`).all() as { id: number }[];
// Find the target and approve everything up to and including it
const toApprove: number[] = [];
for (const plan of pendingPlans) {
toApprove.push(plan.id);
if (plan.id === targetId) break;
}
// Batch approve and create jobs
for (const planId of toApprove) {
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(planId);
const planRow = db.prepare('SELECT item_id, job_type FROM review_plans WHERE id = ?').get(planId) as { item_id: number; job_type: string };
const detail = loadItemDetail(db, planRow.item_id);
if (detail.item && detail.command) {
db.prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, ?, 'pending')")
.run(planRow.item_id, detail.command, planRow.job_type);
}
}
return c.json({ approved: toApprove.length });
});
// ─── Pipeline: series language ───────────────────────────────────────────────
app.patch('/series/:seriesKey/language', async (c) => {
const seriesKey = decodeURIComponent(c.req.param('seriesKey'));
const { language } = await c.req.json<{ language: string }>();
const db = getDb();
const items = db.prepare(
'SELECT id FROM media_items WHERE series_jellyfin_id = ? OR (series_jellyfin_id IS NULL AND series_name = ?)'
).all(seriesKey, seriesKey) as { id: number }[];
const normalizedLang = language ? normalizeLanguage(language) : null;
for (const item of items) {
db.prepare("UPDATE media_items SET original_language = ?, orig_lang_source = 'manual', needs_review = 0 WHERE id = ?")
.run(normalizedLang, item.id);
}
// Re-analyze all episodes
for (const item of items) {
reanalyze(db, item.id);
}
return c.json({ updated: items.length });
});
// ─── Pipeline: summary ───────────────────────────────────────────────────────
app.get('/pipeline', (c) => {
const db = getDb();
const review = db.prepare(`
SELECT rp.*, mi.name, mi.series_name, mi.series_jellyfin_id,
mi.season_number, mi.episode_number, mi.type, mi.container,
mi.original_language, mi.orig_lang_source, mi.file_path
FROM review_plans rp
JOIN media_items mi ON mi.id = rp.item_id
WHERE rp.status = 'pending' AND rp.is_noop = 0
ORDER BY
CASE rp.confidence WHEN 'high' THEN 0 ELSE 1 END,
COALESCE(mi.series_name, mi.name),
mi.season_number, mi.episode_number
`).all();
const queued = db.prepare(`
SELECT j.*, mi.name, mi.series_name, mi.type,
rp.job_type, rp.apple_compat
FROM jobs j
JOIN media_items mi ON mi.id = j.item_id
JOIN review_plans rp ON rp.item_id = j.item_id
WHERE j.status = 'pending'
ORDER BY j.created_at
`).all();
const processing = db.prepare(`
SELECT j.*, mi.name, mi.series_name, mi.type,
rp.job_type, rp.apple_compat
FROM jobs j
JOIN media_items mi ON mi.id = j.item_id
JOIN review_plans rp ON rp.item_id = j.item_id
WHERE j.status = 'running'
`).all();
const done = db.prepare(`
SELECT j.*, mi.name, mi.series_name, mi.type,
rp.job_type, rp.apple_compat
FROM jobs j
JOIN media_items mi ON mi.id = j.item_id
JOIN review_plans rp ON rp.item_id = j.item_id
WHERE j.status IN ('done', 'error')
ORDER BY j.completed_at DESC
LIMIT 50
`).all();
const noops = db.prepare('SELECT COUNT(*) as count FROM review_plans WHERE is_noop = 1').get() as { count: number };
return c.json({ review, queued, processing, done, noopCount: noops.count });
});
export default app;

View File

@@ -164,14 +164,20 @@ async function runScan(limit: number | null = null): Promise<void> {
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const upsertPlan = db.prepare(`
INSERT INTO review_plans (item_id, status, is_noop, notes)
VALUES (?, 'pending', ?, ?)
ON CONFLICT(item_id) DO UPDATE SET is_noop = excluded.is_noop, notes = excluded.notes
INSERT INTO review_plans (item_id, status, is_noop, confidence, apple_compat, job_type, notes)
VALUES (?, 'pending', ?, ?, ?, ?, ?)
ON CONFLICT(item_id) DO UPDATE SET
status = CASE WHEN review_plans.status IN ('done','error') THEN 'pending' ELSE review_plans.status END,
is_noop = excluded.is_noop,
confidence = excluded.confidence,
apple_compat = excluded.apple_compat,
job_type = excluded.job_type,
notes = excluded.notes
`);
const upsertDecision = db.prepare(`
INSERT INTO stream_decisions (plan_id, stream_id, action, target_index)
VALUES (?, ?, ?, ?)
ON CONFLICT(plan_id, stream_id) DO UPDATE SET action = excluded.action, target_index = excluded.target_index
INSERT INTO stream_decisions (plan_id, stream_id, action, target_index, transcode_codec)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(plan_id, stream_id) DO UPDATE SET action = excluded.action, target_index = excluded.target_index, transcode_codec = excluded.transcode_codec
`);
const getItemByJellyfinId = db.prepare('SELECT id FROM media_items WHERE jellyfin_id = ?');
const getPlanByItemId = db.prepare('SELECT id FROM review_plans WHERE item_id = ?');
@@ -213,6 +219,16 @@ async function runScan(limit: number | null = null): Promise<void> {
if (lang) { if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; origLang = lang; origLangSource = 'sonarr'; }
}
// Compute confidence from source agreement
let confidence: 'high' | 'low' = 'low';
if (!origLang) {
confidence = 'low'; // unknown language
} else if (needsReview) {
confidence = 'low'; // sources disagree
} else {
confidence = 'high'; // language known, no conflicts
}
upsertItem.run(
jellyfinItem.Id, jellyfinItem.Type === 'Episode' ? 'Episode' : 'Movie',
jellyfinItem.Name, jellyfinItem.SeriesName ?? null, jellyfinItem.SeriesId ?? null,
@@ -233,10 +249,12 @@ async function runScan(limit: number | null = null): Promise<void> {
}
const streams = getStreamsByItemId.all(itemId) as MediaStream[];
const analysis = analyzeItem({ original_language: origLang, needs_review: needsReview }, streams, { subtitleLanguages, audioLanguages });
upsertPlan.run(itemId, analysis.is_noop ? 1 : 0, analysis.notes);
const analysis = analyzeItem({ original_language: origLang, needs_review: needsReview, container: jellyfinItem.Container ?? null }, streams, { subtitleLanguages, audioLanguages });
// Override base confidence with scan-computed value
const finalConfidence = confidence;
upsertPlan.run(itemId, analysis.is_noop ? 1 : 0, finalConfidence, analysis.apple_compat, analysis.job_type, analysis.notes.length > 0 ? analysis.notes.join('\n') : null);
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);
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 });
} catch (err) {

View File

@@ -57,7 +57,13 @@ export function getDb(): Database {
try { _db.exec("ALTER TABLE nodes ADD COLUMN movies_path TEXT NOT NULL DEFAULT ''"); } catch { /* already exists */ }
try { _db.exec("ALTER TABLE nodes ADD COLUMN series_path TEXT NOT NULL DEFAULT ''"); } catch { /* already exists */ }
try { _db.exec("ALTER TABLE jobs ADD COLUMN job_type TEXT NOT NULL DEFAULT 'audio'"); } catch { /* already exists */ }
// Apple compat pipeline columns
try { _db.exec("ALTER TABLE review_plans ADD COLUMN confidence TEXT NOT NULL DEFAULT 'low'"); } catch { /* already exists */ }
try { _db.exec('ALTER TABLE review_plans ADD COLUMN apple_compat TEXT'); } catch { /* already exists */ }
try { _db.exec("ALTER TABLE review_plans ADD COLUMN job_type TEXT NOT NULL DEFAULT 'copy'"); } catch { /* already exists */ }
try { _db.exec('ALTER TABLE stream_decisions ADD COLUMN transcode_codec TEXT'); } catch { /* already exists */ }
seedDefaults(_db);
return _db;
}

View File

@@ -129,4 +129,8 @@ export const DEFAULT_CONFIG: Record<string, string> = {
audio_languages: '[]',
scan_running: '0',
job_sleep_seconds: '0',
schedule_enabled: '0',
schedule_start: '01:00',
schedule_end: '07:00',
};

View File

@@ -1,5 +1,6 @@
import type { MediaItem, MediaStream, PlanResult } from '../types';
import { normalizeLanguage } from './jellyfin';
import { transcodeTarget, computeAppleCompat } from './apple-compat';
export interface AnalyzerConfig {
subtitleLanguages: string[];
@@ -14,7 +15,7 @@ export interface AnalyzerConfig {
* sidecar files). is_noop only considers audio changes.
*/
export function analyzeItem(
item: Pick<MediaItem, 'original_language' | 'needs_review'>,
item: Pick<MediaItem, 'original_language' | 'needs_review' | 'container'>,
streams: MediaStream[],
config: AnalyzerConfig
): PlanResult {
@@ -24,7 +25,7 @@ export function analyzeItem(
// Compute action for each stream
const decisions: PlanResult['decisions'] = streams.map((s) => {
const action = decideAction(s, origLang, config.audioLanguages);
return { stream_id: s.id, action, target_index: null };
return { stream_id: s.id, action, target_index: null, transcode_codec: null };
});
// Audio-only noop: only consider audio removals/reordering
@@ -38,8 +39,33 @@ export function analyzeItem(
// Check if audio ordering changes
const audioOrderChanged = checkAudioOrderChanged(streams, decisions);
const isNoop = !anyAudioRemoved && !audioOrderChanged;
// Step 3: Apple compatibility — compute transcode targets for kept audio
for (const d of decisions) {
if (d.action === 'keep') {
const stream = streams.find(s => s.id === d.stream_id);
if (stream && stream.type === 'Audio') {
d.transcode_codec = transcodeTarget(
stream.codec ?? '',
stream.title,
item.container,
);
}
}
}
const keptAudioCodecs = decisions
.filter(d => d.action === 'keep')
.map(d => streams.find(s => s.id === d.stream_id))
.filter(s => s && s.type === 'Audio')
.map(s => s!.codec ?? '');
const needsTranscode = decisions.some(d => d.transcode_codec != null);
const apple_compat = computeAppleCompat(keptAudioCodecs, item.container);
const job_type = needsTranscode ? 'transcode' as const : 'copy' as const;
const hasSubs = streams.some((s) => s.type === 'Subtitle');
// Extended is_noop: no audio changes AND no subs to extract AND no transcode needed
const is_noop = !anyAudioRemoved && !audioOrderChanged && !hasSubs && !needsTranscode;
// Generate notes for edge cases
if (!origLang && item.needs_review) {
@@ -47,10 +73,13 @@ export function analyzeItem(
}
return {
is_noop: isNoop,
is_noop,
has_subs: hasSubs,
confidence: 'low',
apple_compat,
job_type,
decisions,
notes: notes.length > 0 ? notes.join('\n') : null,
notes,
};
}

View File

@@ -0,0 +1,66 @@
// Codec sets and transcode target mapping for Apple device compatibility.
// Apple natively decodes: AAC, AC3, EAC3, ALAC, FLAC, MP3, PCM, Opus
// Everything else (DTS family, TrueHD family) needs transcoding.
const APPLE_COMPATIBLE_AUDIO = new Set([
'aac', 'ac3', 'eac3', 'alac', 'flac', 'mp3',
'pcm_s16le', 'pcm_s24le', 'pcm_s32le', 'pcm_f32le',
'pcm_s16be', 'pcm_s24be', 'pcm_s32be', 'pcm_f64le',
'opus',
]);
// Codec strings Jellyfin may report for DTS variants
const DTS_CODECS = new Set([
'dts', 'dca',
]);
const TRUEHD_CODECS = new Set([
'truehd',
]);
export function isAppleCompatible(codec: string): boolean {
return APPLE_COMPATIBLE_AUDIO.has(codec.toLowerCase());
}
/** Maps (codec, profile, container) → target codec for transcoding. */
export function transcodeTarget(
codec: string,
profile: string | null,
container: string | null,
): string | null {
const c = codec.toLowerCase();
const isMkv = !container || container.toLowerCase() === 'mkv' || container.toLowerCase() === 'matroska';
if (isAppleCompatible(c)) return null; // no transcode needed
// DTS-HD MA and DTS:X are lossless → FLAC in MKV, EAC3 in MP4
if (DTS_CODECS.has(c)) {
const p = (profile ?? '').toLowerCase();
const isLossless = p.includes('ma') || p.includes('hd ma') || p.includes('x');
if (isLossless) return isMkv ? 'flac' : 'eac3';
// Lossy DTS variants → EAC3
return 'eac3';
}
// TrueHD (including Atmos) → FLAC in MKV, EAC3 in MP4
if (TRUEHD_CODECS.has(c)) {
return isMkv ? 'flac' : 'eac3';
}
// Any other incompatible codec → EAC3 as safe fallback
return 'eac3';
}
/** Determine overall Apple compatibility for a set of kept audio streams. */
export function computeAppleCompat(
keptAudioCodecs: string[],
container: string | null,
): 'direct_play' | 'remux' | 'audio_transcode' {
const hasIncompatible = keptAudioCodecs.some(c => !isAppleCompatible(c));
if (hasIncompatible) return 'audio_transcode';
const isMkv = !container || container.toLowerCase() === 'mkv' || container.toLowerCase() === 'matroska';
if (isMkv) return 'remux';
return 'direct_play';
}

View File

@@ -361,6 +361,110 @@ export function buildExtractOnlyCommand(
return parts.join(' ');
}
/**
* Build a single FFmpeg command that:
* 1. Extracts subtitles to sidecar files
* 2. Remuxes with reordered/filtered audio
* 3. Transcodes incompatible audio codecs
*/
export function buildPipelineCommand(
item: MediaItem,
streams: MediaStream[],
decisions: (StreamDecision & { stream?: MediaStream })[]
): { command: string; extractedFiles: Array<{ path: string; language: string | null; codec: string | null; is_forced: number; is_hearing_impaired: number }> } {
const inputPath = item.file_path;
const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv';
const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
const basePath = inputPath.replace(/\.[^.]+$/, '');
// --- Subtitle extraction outputs ---
const extractionEntries = computeExtractionEntries(streams, basePath);
const subOutputArgs: string[] = [];
for (const e of extractionEntries) {
subOutputArgs.push(`-map 0:s:${e.typeIdx}`, `-c:s ${e.codecArg}`, shellQuote(e.outPath));
}
// --- Kept streams for remuxed output ---
// Enrich decisions with stream data
const enriched = decisions.map(d => {
const stream = d.stream ?? streams.find(s => s.id === d.stream_id);
return { ...d, stream: stream! };
}).filter(d => d.action === 'keep' && d.stream);
// Sort by type priority then target_index
const typeOrder: Record<string, number> = { Video: 0, Audio: 1, Data: 2, EmbeddedImage: 3 };
enriched.sort((a, b) => {
const ta = typeOrder[a.stream.type] ?? 9;
const tb = typeOrder[b.stream.type] ?? 9;
if (ta !== tb) return ta - tb;
return (a.target_index ?? 0) - (b.target_index ?? 0);
});
// Build -map flags
const maps = buildMaps(streams, enriched.map(d => ({ stream: d.stream, dec: d })));
// Build per-stream codec flags
const codecFlags: string[] = ['-c:v copy'];
let audioIdx = 0;
for (const d of enriched) {
if (d.stream.type === 'Audio') {
if (d.transcode_codec) {
codecFlags.push(`-c:a:${audioIdx} ${d.transcode_codec}`);
// For EAC3, set a reasonable bitrate based on channel count
if (d.transcode_codec === 'eac3') {
const bitrate = (d.stream.channels ?? 2) >= 6 ? '640k' : '256k';
codecFlags.push(`-b:a:${audioIdx} ${bitrate}`);
}
} else {
codecFlags.push(`-c:a:${audioIdx} copy`);
}
audioIdx++;
}
}
// If no audio transcoding, simplify to -c copy (covers video + audio)
const hasTranscode = enriched.some(d => d.transcode_codec);
const finalCodecFlags = hasTranscode ? codecFlags : ['-c copy'];
// Disposition + metadata flags for audio
const streamFlags = buildStreamFlags(enriched.map(d => ({ stream: d.stream, dec: d })));
// Assemble command
const parts: string[] = [
'ffmpeg', '-y',
'-i', shellQuote(inputPath),
];
// Subtitle extraction outputs first
parts.push(...subOutputArgs);
// Map flags for remuxed output
parts.push(...maps);
// Codec flags
parts.push(...finalCodecFlags);
// Stream flags (disposition, metadata)
parts.push(...streamFlags);
// Output file
parts.push(shellQuote(tmpPath));
const command = parts.join(' ')
+ ` && mv ${shellQuote(tmpPath)} ${shellQuote(inputPath)}`;
return {
command,
extractedFiles: extractionEntries.map(e => ({
path: e.outPath,
language: e.stream.language,
codec: e.stream.codec,
is_forced: e.stream.is_forced ? 1 : 0,
is_hearing_impaired: e.stream.is_hearing_impaired ? 1 : 0,
})),
};
}
/** Safely quote a path for shell usage. */
export function shellQuote(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`;

View File

@@ -0,0 +1,81 @@
import { getConfig, setConfig } from '../db';
export interface SchedulerState {
job_sleep_seconds: number;
schedule_enabled: boolean;
schedule_start: string; // "HH:MM"
schedule_end: string; // "HH:MM"
}
export function getSchedulerState(): SchedulerState {
return {
job_sleep_seconds: parseInt(getConfig('job_sleep_seconds') ?? '0', 10),
schedule_enabled: getConfig('schedule_enabled') === '1',
schedule_start: getConfig('schedule_start') ?? '01:00',
schedule_end: getConfig('schedule_end') ?? '07:00',
};
}
export function updateSchedulerState(updates: Partial<SchedulerState>): void {
if (updates.job_sleep_seconds != null) setConfig('job_sleep_seconds', String(updates.job_sleep_seconds));
if (updates.schedule_enabled != null) setConfig('schedule_enabled', updates.schedule_enabled ? '1' : '0');
if (updates.schedule_start != null) setConfig('schedule_start', updates.schedule_start);
if (updates.schedule_end != null) setConfig('schedule_end', updates.schedule_end);
}
/** Check if current time is within the schedule window. */
export function isInScheduleWindow(): boolean {
const state = getSchedulerState();
if (!state.schedule_enabled) return true; // no schedule = always allowed
const now = new Date();
const minutes = now.getHours() * 60 + now.getMinutes();
const start = parseTime(state.schedule_start);
const end = parseTime(state.schedule_end);
// Handle overnight windows (e.g., 23:00 → 07:00)
if (start <= end) {
return minutes >= start && minutes < end;
} else {
return minutes >= start || minutes < end;
}
}
/** Returns milliseconds until the next schedule window opens. */
export function msUntilWindow(): number {
const state = getSchedulerState();
const now = new Date();
const minutes = now.getHours() * 60 + now.getMinutes();
const start = parseTime(state.schedule_start);
if (minutes < start) {
return (start - minutes) * 60_000;
} else {
// Next day
return (24 * 60 - minutes + start) * 60_000;
}
}
/** Returns the schedule_start time as "HH:MM" for display. */
export function nextWindowTime(): string {
return getSchedulerState().schedule_start;
}
function parseTime(hhmm: string): number {
const [h, m] = hhmm.split(':').map(Number);
return h * 60 + m;
}
/** Sleep for the configured duration between jobs. */
export function sleepBetweenJobs(): Promise<void> {
const seconds = getSchedulerState().job_sleep_seconds;
if (seconds <= 0) return Promise.resolve();
return new Promise(resolve => setTimeout(resolve, seconds * 1000));
}
/** Wait until the schedule window opens. Resolves immediately if already in window. */
export function waitForWindow(): Promise<void> {
if (isInScheduleWindow()) return Promise.resolve();
const ms = msUntilWindow();
return new Promise(resolve => setTimeout(resolve, ms));
}

View File

@@ -48,6 +48,9 @@ export interface ReviewPlan {
item_id: number;
status: 'pending' | 'approved' | 'skipped' | 'done' | 'error';
is_noop: number;
confidence: 'high' | 'low';
apple_compat: 'direct_play' | 'remux' | 'audio_transcode' | null;
job_type: 'copy' | 'transcode';
subs_extracted: number;
notes: string | null;
reviewed_at: string | null;
@@ -73,13 +76,14 @@ export interface StreamDecision {
action: 'keep' | 'remove';
target_index: number | null;
custom_title: string | null;
transcode_codec: string | null;
}
export interface Job {
id: number;
item_id: number;
command: string;
job_type: 'audio' | 'subtitle';
job_type: 'copy' | 'transcode';
node_id: number | null;
status: 'pending' | 'running' | 'done' | 'error';
output: string | null;
@@ -115,8 +119,11 @@ export interface StreamWithDecision extends MediaStream {
export interface PlanResult {
is_noop: boolean;
has_subs: boolean;
decisions: Array<{ stream_id: number; action: 'keep' | 'remove'; target_index: number | null }>;
notes: string | null;
confidence: 'high' | 'low';
apple_compat: 'direct_play' | 'remux' | 'audio_transcode' | null;
job_type: 'copy' | 'transcode';
decisions: Array<{ stream_id: number; action: 'keep' | 'remove'; target_index: number | null; transcode_codec: string | null }>;
notes: string[];
}
// ─── Jellyfin API types ───────────────────────────────────────────────────────

View File

@@ -0,0 +1,28 @@
import { Badge } from '~/shared/components/ui/badge';
interface DoneColumnProps {
items: any[];
}
export function DoneColumn({ items }: DoneColumnProps) {
return (
<div className="flex flex-col w-64 min-w-64 bg-gray-50 rounded-lg">
<div className="px-3 py-2 border-b font-medium text-sm">
Done <span className="text-gray-400">({items.length})</span>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{items.map((item: any) => (
<div key={item.id} className="rounded border bg-white p-2">
<p className="text-xs font-medium truncate">{item.name}</p>
<Badge variant={item.status === 'done' ? 'done' : 'error'}>
{item.status}
</Badge>
</div>
))}
{items.length === 0 && (
<p className="text-sm text-gray-400 text-center py-8">No completed items</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { Badge } from '~/shared/components/ui/badge';
import { LANG_NAMES, langName } from '~/shared/lib/lang';
interface PipelineCardProps {
item: any;
onLanguageChange?: (lang: string) => void;
showApproveUpTo?: boolean;
onApproveUpTo?: () => void;
}
export function PipelineCard({ item, onLanguageChange, showApproveUpTo, onApproveUpTo }: PipelineCardProps) {
const title = item.type === 'Episode'
? `S${String(item.season_number).padStart(2, '0')}E${String(item.episode_number).padStart(2, '0')}${item.name}`
: item.name;
const confidenceColor = item.confidence === 'high' ? 'bg-green-50 border-green-200' : 'bg-amber-50 border-amber-200';
return (
<div className={`rounded-lg border p-3 ${confidenceColor}`}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{title}</p>
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
{onLanguageChange ? (
<select
className="h-6 text-xs border border-gray-300 rounded px-1 bg-white"
value={item.original_language ?? ''}
onChange={(e) => onLanguageChange(e.target.value)}
>
<option value="">unknown</option>
{Object.entries(LANG_NAMES).map(([code, name]) => (
<option key={code} value={code}>{name}</option>
))}
</select>
) : (
<Badge variant="default">{langName(item.original_language)}</Badge>
)}
{item.apple_compat === 'audio_transcode' && (
<Badge variant="manual">transcode</Badge>
)}
{item.job_type === 'copy' && item.apple_compat !== 'audio_transcode' && (
<Badge variant="noop">copy</Badge>
)}
</div>
</div>
</div>
{showApproveUpTo && onApproveUpTo && (
<button
onClick={onApproveUpTo}
className="mt-2 w-full text-xs py-1 rounded bg-blue-600 text-white hover:bg-blue-700 cursor-pointer"
>
Approve up to here
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { useCallback, useEffect, useState } from 'react';
import { api } from '~/shared/lib/api';
import { ReviewColumn } from './ReviewColumn';
import { QueueColumn } from './QueueColumn';
import { ProcessingColumn } from './ProcessingColumn';
import { DoneColumn } from './DoneColumn';
import { ScheduleControls } from './ScheduleControls';
interface PipelineData {
review: any[];
queued: any[];
processing: any[];
done: any[];
noopCount: number;
}
interface SchedulerState {
job_sleep_seconds: number;
schedule_enabled: boolean;
schedule_start: string;
schedule_end: string;
}
interface Progress {
id: number;
seconds: number;
total: number;
}
interface QueueStatus {
status: string;
until?: string;
seconds?: number;
}
export function PipelinePage() {
const [data, setData] = useState<PipelineData | null>(null);
const [scheduler, setScheduler] = useState<SchedulerState | null>(null);
const [progress, setProgress] = useState<Progress | null>(null);
const [queueStatus, setQueueStatus] = useState<QueueStatus | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
const [pipelineRes, schedulerRes] = await Promise.all([
api.get<PipelineData>('/api/review/pipeline'),
api.get<SchedulerState>('/api/execute/scheduler'),
]);
setData(pipelineRes);
setScheduler(schedulerRes);
setLoading(false);
}, []);
useEffect(() => { load(); }, [load]);
// SSE for live updates
useEffect(() => {
const es = new EventSource('/api/execute/events');
es.addEventListener('job_update', () => load());
es.addEventListener('job_progress', (e) => {
setProgress(JSON.parse((e as MessageEvent).data));
});
es.addEventListener('queue_status', (e) => {
setQueueStatus(JSON.parse((e as MessageEvent).data));
});
return () => es.close();
}, [load]);
if (loading || !data) return <div className="p-6 text-gray-500">Loading pipeline...</div>;
return (
<div className="flex flex-col h-[calc(100vh-4rem)]">
<div className="flex items-center justify-between px-6 py-3 border-b">
<h1 className="text-lg font-semibold">Pipeline</h1>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-500">{data.noopCount} files already processed</span>
{scheduler && <ScheduleControls scheduler={scheduler} onUpdate={load} />}
</div>
</div>
<div className="flex flex-1 gap-4 p-4 overflow-x-auto">
<ReviewColumn items={data.review} onMutate={load} />
<QueueColumn items={data.queued} />
<ProcessingColumn items={data.processing} progress={progress} queueStatus={queueStatus} />
<DoneColumn items={data.done} />
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { Badge } from '~/shared/components/ui/badge';
interface ProcessingColumnProps {
items: any[];
progress?: { id: number; seconds: number; total: number } | null;
queueStatus?: { status: string; until?: string; seconds?: number } | null;
}
export function ProcessingColumn({ items, progress, queueStatus }: ProcessingColumnProps) {
const job = items[0]; // at most one running job
const formatTime = (s: number) => {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${String(sec).padStart(2, '0')}`;
};
return (
<div className="flex flex-col w-72 min-w-72 bg-gray-50 rounded-lg">
<div className="px-3 py-2 border-b font-medium text-sm">Processing</div>
<div className="flex-1 p-3">
{queueStatus && queueStatus.status !== 'running' && (
<div className="mb-3 text-xs text-gray-500 bg-white rounded border p-2">
{queueStatus.status === 'paused' && <>Paused until {queueStatus.until}</>}
{queueStatus.status === 'sleeping' && <>Sleeping {queueStatus.seconds}s between jobs</>}
{queueStatus.status === 'idle' && <>Idle</>}
</div>
)}
{job ? (
<div className="rounded border bg-white p-3">
<p className="text-sm font-medium truncate">{job.name}</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="running">running</Badge>
<Badge variant={job.job_type === 'transcode' ? 'manual' : 'noop'}>
{job.job_type}
</Badge>
</div>
{progress && progress.total > 0 && (
<div className="mt-3">
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span>{formatTime(progress.seconds)}</span>
<span>{Math.round((progress.seconds / progress.total) * 100)}%</span>
<span>{formatTime(progress.total)}</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{ width: `${Math.min(100, (progress.seconds / progress.total) * 100)}%` }}
/>
</div>
</div>
)}
</div>
) : (
<p className="text-sm text-gray-400 text-center py-8">No active job</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { Badge } from '~/shared/components/ui/badge';
interface QueueColumnProps {
items: any[];
}
export function QueueColumn({ items }: QueueColumnProps) {
return (
<div className="flex flex-col w-64 min-w-64 bg-gray-50 rounded-lg">
<div className="px-3 py-2 border-b font-medium text-sm">
Queued <span className="text-gray-400">({items.length})</span>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{items.map((item: any) => (
<div key={item.id} className="rounded border bg-white p-2">
<p className="text-xs font-medium truncate">{item.name}</p>
<Badge variant={item.job_type === 'transcode' ? 'manual' : 'noop'}>
{item.job_type}
</Badge>
</div>
))}
{items.length === 0 && (
<p className="text-sm text-gray-400 text-center py-8">Queue empty</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { api } from '~/shared/lib/api';
import { PipelineCard } from './PipelineCard';
import { SeriesCard } from './SeriesCard';
interface ReviewColumnProps {
items: any[];
onMutate: () => void;
}
export function ReviewColumn({ items, onMutate }: ReviewColumnProps) {
// Group by series (movies are standalone)
const movies = items.filter((i: any) => i.type === 'Movie');
const seriesMap = new Map<string, { name: string; key: string; episodes: any[] }>();
for (const item of items.filter((i: any) => i.type === 'Episode')) {
const key = item.series_jellyfin_id ?? item.series_name;
if (!seriesMap.has(key)) {
seriesMap.set(key, { name: item.series_name, key, episodes: [] });
}
seriesMap.get(key)!.episodes.push(item);
}
const approveUpTo = async (planId: number) => {
await api.post(`/api/review/approve-up-to/${planId}`);
onMutate();
};
// Interleave movies and series, sorted by confidence (high first)
const allItems = [
...movies.map((m: any) => ({ type: 'movie' as const, item: m, sortKey: m.confidence === 'high' ? 0 : 1 })),
...[...seriesMap.values()].map(s => ({
type: 'series' as const,
item: s,
sortKey: s.episodes.every((e: any) => e.confidence === 'high') ? 0 : 1,
})),
].sort((a, b) => a.sortKey - b.sortKey);
return (
<div className="flex flex-col w-80 min-w-80 bg-gray-50 rounded-lg">
<div className="px-3 py-2 border-b font-medium text-sm">
Review <span className="text-gray-400">({items.length})</span>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-2">
{allItems.map((entry) => {
if (entry.type === 'movie') {
return (
<PipelineCard
key={entry.item.id}
item={entry.item}
onLanguageChange={async (lang) => {
await api.patch(`/api/review/${entry.item.item_id}/language`, { language: lang });
onMutate();
}}
showApproveUpTo
onApproveUpTo={() => approveUpTo(entry.item.id)}
/>
);
} else {
return (
<SeriesCard
key={entry.item.key}
seriesKey={entry.item.key}
seriesName={entry.item.name}
episodes={entry.item.episodes}
onMutate={onMutate}
/>
);
}
})}
{allItems.length === 0 && (
<p className="text-sm text-gray-400 text-center py-8">No items to review</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { useState } from 'react';
import { api } from '~/shared/lib/api';
import { Input } from '~/shared/components/ui/input';
import { Button } from '~/shared/components/ui/button';
interface ScheduleControlsProps {
scheduler: {
job_sleep_seconds: number;
schedule_enabled: boolean;
schedule_start: string;
schedule_end: string;
};
onUpdate: () => void;
}
export function ScheduleControls({ scheduler, onUpdate }: ScheduleControlsProps) {
const [open, setOpen] = useState(false);
const [state, setState] = useState(scheduler);
const save = async () => {
await api.patch('/api/execute/scheduler', state);
onUpdate();
setOpen(false);
};
const startAll = async () => {
await api.post('/api/execute/start');
onUpdate();
};
return (
<div className="relative flex items-center gap-2">
<Button variant="primary" size="sm" onClick={startAll}>
Start queue
</Button>
<button
onClick={() => setOpen(!open)}
className="text-xs text-gray-500 hover:text-gray-700 cursor-pointer"
>
Schedule settings
</button>
{open && (
<div className="absolute right-0 top-10 z-50 bg-white border rounded-lg shadow-lg p-4 w-72">
<h3 className="text-sm font-medium mb-3">Schedule Settings</h3>
<label className="block text-xs text-gray-600 mb-1">Sleep between jobs (seconds)</label>
<Input
type="number"
min={0}
value={state.job_sleep_seconds}
onChange={(e) => setState({ ...state, job_sleep_seconds: parseInt(e.target.value) || 0 })}
className="mb-3"
/>
<label className="flex items-center gap-2 text-xs text-gray-600 mb-2">
<input
type="checkbox"
checked={state.schedule_enabled}
onChange={(e) => setState({ ...state, schedule_enabled: e.target.checked })}
/>
Enable time window
</label>
{state.schedule_enabled && (
<div className="flex items-center gap-2 mb-3">
<Input
type="time"
value={state.schedule_start}
onChange={(e) => setState({ ...state, schedule_start: e.target.value })}
className="w-24"
/>
<span className="text-xs text-gray-500">to</span>
<Input
type="time"
value={state.schedule_end}
onChange={(e) => setState({ ...state, schedule_end: e.target.value })}
className="w-24"
/>
</div>
)}
<Button variant="primary" size="sm" onClick={save}>Save</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { useState } from 'react';
import { api } from '~/shared/lib/api';
import { LANG_NAMES } from '~/shared/lib/lang';
import { PipelineCard } from './PipelineCard';
interface SeriesCardProps {
seriesKey: string;
seriesName: string;
episodes: any[];
onMutate: () => void;
}
export function SeriesCard({ seriesKey, seriesName, episodes, onMutate }: SeriesCardProps) {
const [expanded, setExpanded] = useState(false);
const seriesLang = episodes[0]?.original_language ?? '';
const setSeriesLanguage = async (lang: string) => {
await api.patch(`/api/review/series/${encodeURIComponent(seriesKey)}/language`, { language: lang });
onMutate();
};
const approveSeries = async () => {
await api.post(`/api/review/series/${encodeURIComponent(seriesKey)}/approve-all`);
onMutate();
};
const highCount = episodes.filter((e: any) => e.confidence === 'high').length;
const lowCount = episodes.filter((e: any) => e.confidence === 'low').length;
return (
<div className="rounded-lg border bg-white">
<div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-gray-50"
onClick={() => setExpanded(!expanded)}
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-gray-400">{expanded ? '▼' : '▶'}</span>
<p className="text-sm font-medium truncate">{seriesName}</p>
<span className="text-xs text-gray-500">{episodes.length} eps</span>
{highCount > 0 && <span className="text-xs text-green-600">{highCount} ready</span>}
{lowCount > 0 && <span className="text-xs text-amber-600">{lowCount} review</span>}
</div>
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<select
className="h-6 text-xs border border-gray-300 rounded px-1 bg-white"
value={seriesLang}
onChange={(e) => setSeriesLanguage(e.target.value)}
>
<option value="">unknown</option>
{Object.entries(LANG_NAMES).map(([code, name]) => (
<option key={code} value={code}>{name}</option>
))}
</select>
<button
onClick={approveSeries}
className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 cursor-pointer whitespace-nowrap"
>
Approve all
</button>
</div>
</div>
{expanded && (
<div className="border-t px-3 pb-3 space-y-2 pt-2">
{episodes.map((ep: any) => (
<PipelineCard
key={ep.id}
item={ep}
onLanguageChange={async (lang) => {
await api.patch(`/api/review/${ep.item_id}/language`, { language: lang });
onMutate();
}}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { Link } from '@tanstack/react-router';
import { api } from '~/shared/lib/api';
import { Button } from '~/shared/components/ui/button';
import { Badge } from '~/shared/components/ui/badge';
@@ -29,6 +30,7 @@ export function ScanPage() {
const [limit, setLimit] = useState('');
const [log, setLog] = useState<LogEntry[]>([]);
const [statusLabel, setStatusLabel] = useState('');
const [scanComplete, setScanComplete] = useState(false);
const [currentItem, setCurrentItem] = useState('');
const [progressScanned, setProgressScanned] = useState(0);
const [progressTotal, setProgressTotal] = useState(0);
@@ -58,6 +60,7 @@ export function ScanPage() {
const d = b.complete;
b.complete = null;
setStatusLabel(`Scan complete — ${d.scanned ?? '?'} items, ${d.errors ?? 0} errors`);
setScanComplete(true);
setStatus((prev) => prev ? { ...prev, running: false } : prev);
stopFlushing();
}
@@ -150,6 +153,7 @@ export function ScanPage() {
setErrors(0);
setCurrentItem('');
setStatusLabel('Scan in progress…');
setScanComplete(false);
setStatus((prev) => prev ? { ...prev, running: true } : prev);
bufRef.current = freshBuf();
@@ -179,6 +183,11 @@ export function ScanPage() {
<div className="border border-gray-200 rounded-lg px-4 py-3 mb-6">
<div className="flex items-center flex-wrap gap-2 mb-3">
<span className="text-sm font-medium">{statusLabel || (running ? 'Scan in progress…' : 'Scan idle')}</span>
{scanComplete && (
<Link to="/pipeline" className="text-blue-600 hover:underline text-sm">
Review in Pipeline
</Link>
)}
{running ? (
<Button variant="secondary" size="sm" onClick={stopScan}>Stop</Button>
) : (

View File

@@ -51,11 +51,8 @@ function RootLayout() {
<VersionBadge />
<div className="flex flex-wrap items-center gap-0.5">
<NavLink to="/scan">Scan</NavLink>
<NavLink to="/paths">Paths</NavLink>
<NavLink to="/review/audio">Audio</NavLink>
<NavLink to="/review/subtitles/extract">ST Extract</NavLink>
<NavLink to="/review/subtitles">ST Manager</NavLink>
<NavLink to="/execute">Execute</NavLink>
<NavLink to="/pipeline">Pipeline</NavLink>
<NavLink to="/review/subtitles">Subtitles</NavLink>
</div>
<div className="flex-1" />
<div className="flex items-center gap-0.5">

6
src/routes/pipeline.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { PipelinePage } from '~/features/pipeline/PipelinePage';
export const Route = createFileRoute('/pipeline')({
component: PipelinePage,
});

View File

@@ -46,6 +46,9 @@ export interface ReviewPlan {
item_id: number;
status: string;
is_noop: number;
confidence: 'high' | 'low';
apple_compat: 'direct_play' | 'remux' | 'audio_transcode' | null;
job_type: 'copy' | 'transcode';
subs_extracted: number;
notes: string | null;
reviewed_at: string | null;
@@ -71,6 +74,7 @@ export interface StreamDecision {
action: 'keep' | 'remove';
target_index: number | null;
custom_title: string | null;
transcode_codec: string | null;
}
export interface Job {
@@ -78,7 +82,7 @@ export interface Job {
item_id: number;
node_id: number | null;
command: string;
job_type: 'audio' | 'subtitle';
job_type: 'copy' | 'transcode';
status: 'pending' | 'running' | 'done' | 'error';
output: string | null;
exit_code: number | null;