split audio/subtitle concerns, remove docker-in-docker, add per-node path mapping
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m54s

- install ffmpeg in dockerfile (fixes exit code 127)
- buildCommand() now audio-only remux, no subtitle extraction
- add unapprove endpoint + ui button for approved items
- add batch extract-all subtitles endpoint + ui button
- audio detail page shows only video+audio streams
- remove global movies_path/series_path config, add per-node path mapping
- remove docker-in-docker command building (buildDockerCommand, buildDockerExtractOnlyCommand)
- ssh execution translates /movies/ and /series/ to node-specific paths
- remove media paths section from setup page
- add unraid-template.xml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 16:48:00 +01:00
parent 36080951ef
commit d5f4afd26b
19 changed files with 171 additions and 286 deletions

View File

@@ -6,3 +6,4 @@ data/
.claude/
.env*
*.md
*.xml

View File

@@ -6,13 +6,14 @@ COPY . .
RUN npx vite build
FROM oven/bun:1
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile --production
COPY --from=build /app/dist ./dist
COPY --from=build /app/server ./server
COPY --from=build /app/dist/ ./dist/
COPY --from=build /app/server/ ./server/
EXPOSE 3000
ENV DATA_DIR=/data
ENV DATA_DIR=/data/
ENV PORT=3000
VOLUME ["/data"]
VOLUME ["/data/"]
CMD ["bun", "run", "server/index.tsx"]

View File

@@ -4,8 +4,10 @@ services:
ports:
- "3000:3000"
volumes:
- ./data:/data
- ./data/:/data/
- /mnt/user/media/movies/:/movies/
- /mnt/user/media/series/:/series/
environment:
- DATA_DIR=/data
- DATA_DIR=/data/
- PORT=3000
restart: unless-stopped

View File

@@ -177,7 +177,11 @@ async function runJob(job: Job): Promise<void> {
if (job.node_id) {
const node = db.prepare('SELECT * FROM nodes WHERE id = ?').get(job.node_id) as Node | undefined;
if (!node) throw new Error(`Node ${job.node_id} not found`);
for await (const line of execStream(node, job.command)) { outputLines.push(line); flush(); }
// Translate container paths to node-specific mount paths
let cmd = job.command;
if (node.movies_path) cmd = cmd.replaceAll('/movies/', node.movies_path.replace(/\/$/, '') + '/');
if (node.series_path) cmd = cmd.replaceAll('/series/', node.series_path.replace(/\/$/, '') + '/');
for await (const line of execStream(node, cmd)) { outputLines.push(line); flush(); }
} else {
const proc = Bun.spawn(['sh', '-c', job.command], { stdout: 'pipe', stderr: 'pipe' });
const readStream = async (readable: ReadableStream<Uint8Array>, prefix = '') => {

View File

@@ -14,7 +14,7 @@ app.get('/', (c) => {
app.post('/', async (c) => {
const db = getDb();
const contentType = c.req.header('Content-Type') ?? '';
let name: string, host: string, port: number, username: string, ffmpegPath: string, workDir: string, privateKey: string;
let name: string, host: string, port: number, username: string, ffmpegPath: string, workDir: string, privateKey: string, moviesPath: string, seriesPath: string;
// Support both multipart (file upload) and JSON
if (contentType.includes('multipart/form-data')) {
@@ -25,19 +25,23 @@ app.post('/', async (c) => {
username = body.get('username') as string;
ffmpegPath = (body.get('ffmpeg_path') as string) || 'ffmpeg';
workDir = (body.get('work_dir') as string) || '/tmp';
moviesPath = (body.get('movies_path') as string) || '';
seriesPath = (body.get('series_path') as string) || '';
const keyFile = body.get('private_key') as File | null;
if (!name || !host || !username || !keyFile) return c.json({ ok: false, error: 'All fields are required' }, 400);
privateKey = await keyFile.text();
} else {
const body = await c.req.json<{ name: string; host: string; port?: number; username: string; ffmpeg_path?: string; work_dir?: string; private_key: string }>();
const body = await c.req.json<{ name: string; host: string; port?: number; username: string; ffmpeg_path?: string; work_dir?: string; movies_path?: string; series_path?: string; private_key: string }>();
name = body.name; host = body.host; port = body.port ?? 22; username = body.username;
ffmpegPath = body.ffmpeg_path || 'ffmpeg'; workDir = body.work_dir || '/tmp'; privateKey = body.private_key;
ffmpegPath = body.ffmpeg_path || 'ffmpeg'; workDir = body.work_dir || '/tmp';
moviesPath = body.movies_path || ''; seriesPath = body.series_path || '';
privateKey = body.private_key;
if (!name || !host || !username || !privateKey) return c.json({ ok: false, error: 'All fields are required' }, 400);
}
try {
db.prepare('INSERT INTO nodes (name, host, port, username, private_key, ffmpeg_path, work_dir) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(name, host, port, username, privateKey, ffmpegPath, workDir);
db.prepare('INSERT INTO nodes (name, host, port, username, private_key, ffmpeg_path, work_dir, movies_path, series_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
.run(name, host, port, username, privateKey, ffmpegPath, workDir, moviesPath, seriesPath);
} catch (e) {
if (String(e).includes('UNIQUE')) return c.json({ ok: false, error: `A node named "${name}" already exists` }, 409);
throw e;

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono';
import { getDb, getConfig, getAllConfig } from '../db/index';
import { analyzeItem } from '../services/analyzer';
import { buildCommand, buildDockerCommand } from '../services/ffmpeg';
import { buildCommand } from '../services/ffmpeg';
import { normalizeLanguage, getItem, refreshItem, mapStream } from '../services/jellyfin';
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '../types';
@@ -51,23 +51,15 @@ function rowToPlan(r: RawRow): ReviewPlan | null {
function loadItemDetail(db: ReturnType<typeof getDb>, itemId: number) {
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, dockerCommand: null, dockerMountDir: 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 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 cfg = getAllConfig();
let dockerCommand: string | null = null;
let dockerMountDir: string | null = null;
if (plan && !plan.is_noop) {
const result = buildDockerCommand(item, streams, decisions, { moviesPath: cfg.movies_path || undefined, seriesPath: cfg.series_path || undefined });
dockerCommand = result.command;
dockerMountDir = result.mountDir;
}
return { item, streams, plan: plan ?? null, decisions, command, dockerCommand, dockerMountDir };
return { item, streams, plan: plan ?? null, decisions, command };
}
function reanalyze(db: ReturnType<typeof getDb>, itemId: number): void {
@@ -317,6 +309,23 @@ app.post('/:id/approve', (c) => {
return c.json({ ok: true });
});
// ─── Unapprove ───────────────────────────────────────────────────────────────
app.post('/:id/unapprove', (c) => {
const db = getDb();
const id = Number(c.req.param('id'));
const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(id) as ReviewPlan | undefined;
if (!plan) return c.notFound();
if (plan.status !== 'approved') return c.json({ ok: false, error: 'Can only unapprove items with status approved' }, 409);
// Only allow if the associated job hasn't started yet
const job = db.prepare("SELECT * FROM jobs WHERE item_id = ? ORDER BY created_at DESC LIMIT 1").get(id) as { id: number; status: string } | undefined;
if (job && job.status !== 'pending') return c.json({ ok: false, error: 'Job already started — cannot unapprove' }, 409);
// Delete the pending job and revert plan status
if (job) db.prepare('DELETE FROM jobs WHERE id = ?').run(job.id);
db.prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE id = ?").run(plan.id);
return c.json({ ok: true });
});
// ─── Skip / Unskip ───────────────────────────────────────────────────────────
app.post('/:id/skip', (c) => {

View File

@@ -83,15 +83,6 @@ app.post('/subtitle-languages', async (c) => {
return c.json({ ok: true });
});
app.post('/paths', async (c) => {
const body = await c.req.json<{ movies_path?: string; series_path?: string }>();
const moviesPath = (body.movies_path ?? '').trim().replace(/\/$/, '');
const seriesPath = (body.series_path ?? '').trim().replace(/\/$/, '');
setConfig('movies_path', moviesPath);
setConfig('series_path', seriesPath);
return c.json({ ok: true });
});
app.post('/clear-scan', (c) => {
const db = getDb();
db.prepare('DELETE FROM media_items').run();

View File

@@ -1,6 +1,6 @@
import { Hono } from 'hono';
import { getDb, getAllConfig } from '../db/index';
import { buildExtractOnlyCommand, buildDockerExtractOnlyCommand, predictExtractedFiles } from '../services/ffmpeg';
import { buildExtractOnlyCommand } from '../services/ffmpeg';
import { normalizeLanguage, getItem, refreshItem, mapStream } from '../services/jellyfin';
import type { MediaItem, MediaStream, SubtitleFile, ReviewPlan, StreamDecision } from '../types';
import { unlinkSync } from 'node:fs';
@@ -21,8 +21,6 @@ function loadDetail(db: ReturnType<typeof getDb>, itemId: number) {
: [];
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 cfg = getAllConfig();
const dockerResult = buildDockerExtractOnlyCommand(item, allStreams, { moviesPath: cfg.movies_path || undefined, seriesPath: cfg.series_path || undefined });
return {
item,
@@ -32,8 +30,6 @@ function loadDetail(db: ReturnType<typeof getDb>, itemId: number) {
decisions,
subs_extracted: plan?.subs_extracted ?? 0,
extractCommand,
dockerCommand: dockerResult?.command ?? null,
dockerMountDir: dockerResult?.mountDir ?? null,
};
}
@@ -129,6 +125,30 @@ app.patch('/:id/stream/:streamId/title', async (c) => {
return c.json(detail);
});
// ─── Extract all ──────────────────────────────────────────────────────────────
app.post('/extract-all', (c) => {
const db = getDb();
// Find items with subtitle streams that haven't been extracted yet
const items = db.prepare(`
SELECT mi.* FROM media_items mi
WHERE EXISTS (SELECT 1 FROM media_streams ms WHERE ms.item_id = mi.id AND ms.type = 'Subtitle')
AND NOT EXISTS (SELECT 1 FROM review_plans rp WHERE rp.item_id = mi.id AND rp.subs_extracted = 1)
AND NOT EXISTS (SELECT 1 FROM jobs j WHERE j.item_id = mi.id AND j.status IN ('pending', 'running'))
`).all() as MediaItem[];
let queued = 0;
for (const item of items) {
const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(item.id) as MediaStream[];
const command = buildExtractOnlyCommand(item, streams);
if (!command) continue;
db.prepare("INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')").run(item.id, command);
queued++;
}
return c.json({ ok: true, queued });
});
// ─── Extract ─────────────────────────────────────────────────────────────────
app.post('/:id/extract', (c) => {

View File

@@ -22,8 +22,6 @@ const ENV_MAP: Record<string, string> = {
sonarr_api_key: 'SONARR_API_KEY',
sonarr_enabled: 'SONARR_ENABLED',
subtitle_languages: 'SUBTITLE_LANGUAGES',
movies_path: 'MOVIES_PATH',
series_path: 'SERIES_PATH',
};
/** Read a config key from environment variables (returns null if not set). */
@@ -54,6 +52,8 @@ export function getDb(): Database {
// Migrations for columns added after initial release
try { _db.exec('ALTER TABLE stream_decisions ADD COLUMN custom_title TEXT'); } catch { /* already exists */ }
try { _db.exec('ALTER TABLE review_plans ADD COLUMN subs_extracted INTEGER NOT NULL DEFAULT 0'); } catch { /* already exists */ }
try { _db.exec("ALTER TABLE 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 */ }
seedDefaults(_db);
return _db;
}

View File

@@ -16,6 +16,8 @@ CREATE TABLE IF NOT EXISTS nodes (
private_key TEXT NOT NULL,
ffmpeg_path TEXT NOT NULL DEFAULT 'ffmpeg',
work_dir TEXT NOT NULL DEFAULT '/tmp',
movies_path TEXT NOT NULL DEFAULT '',
series_path TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'unknown',
last_checked_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
@@ -124,6 +126,4 @@ export const DEFAULT_CONFIG: Record<string, string> = {
sonarr_enabled: '0',
subtitle_languages: JSON.stringify(['eng', 'deu', 'spa']),
scan_running: '0',
movies_path: '',
series_path: '',
};

View File

@@ -202,15 +202,14 @@ function buildMaps(
}
/**
* Build disposition and metadata flags for kept audio + subtitle streams.
* Build disposition and metadata flags for kept audio streams.
* - Marks the first kept audio stream as default, clears all others.
* - Sets harmonized language-name titles on all kept audio/subtitle streams.
* - Sets harmonized language-name titles on all kept audio streams.
*/
function buildStreamFlags(
kept: { stream: MediaStream; dec: StreamDecision }[]
): string[] {
const audioKept = kept.filter((k) => k.stream.type === 'Audio');
const subKept = kept.filter((k) => k.stream.type === 'Subtitle');
const args: string[] = [];
// Disposition: first audio = default, rest = clear
@@ -224,12 +223,6 @@ function buildStreamFlags(
if (title) args.push(`-metadata:s:a:${i}`, `title=${shellQuote(title)}`);
});
// Titles for subtitle streams (custom_title overrides generated title)
subKept.forEach((k, i) => {
const title = k.dec.custom_title ?? trackTitle(k.stream);
if (title) args.push(`-metadata:s:s:${i}`, `title=${shellQuote(title)}`);
});
return args;
}
@@ -264,11 +257,9 @@ export function buildCommand(
const inputPath = item.file_path;
const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv';
const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
const basePath = inputPath.replace(/\.[^.]+$/, '');
const maps = buildMaps(streams, kept);
const streamFlags = buildStreamFlags(kept);
const extractionOutputs = buildExtractionOutputs(streams, basePath);
const parts: string[] = [
'ffmpeg',
@@ -278,7 +269,6 @@ export function buildCommand(
...streamFlags,
'-c copy',
shellQuote(tmpPath),
...extractionOutputs,
'&&',
'mv', shellQuote(tmpPath), shellQuote(inputPath),
];
@@ -298,7 +288,6 @@ export function buildMkvConvertCommand(
const inputPath = item.file_path;
const outputPath = inputPath.replace(/\.[^.]+$/, '.mkv');
const tmpPath = inputPath.replace(/\.[^.]+$/, '.tmp.mkv');
const basePath = outputPath.replace(/\.[^.]+$/, '');
const kept = streams
.map((s) => {
@@ -317,7 +306,6 @@ export function buildMkvConvertCommand(
const maps = buildMaps(streams, kept);
const streamFlags = buildStreamFlags(kept);
const extractionOutputs = buildExtractionOutputs(streams, basePath);
return [
'ffmpeg', '-y',
@@ -327,103 +315,11 @@ export function buildMkvConvertCommand(
'-c copy',
'-f matroska',
shellQuote(tmpPath),
...extractionOutputs,
'&&',
'mv', shellQuote(tmpPath), shellQuote(outputPath),
].join(' ');
}
/**
* Build a Docker-wrapped version of the FFmpeg command.
* Mounts the file's directory to /work inside the container and rewrites
* all paths accordingly. Requires only Docker as a system dependency.
*
* Image: jrottenberg/ffmpeg — entrypoint is ffmpeg, so we use --entrypoint sh
* to run ffmpeg + mv in a single shell invocation.
*/
export function buildDockerCommand(
item: MediaItem,
streams: MediaStream[],
decisions: StreamDecision[],
opts: { moviesPath?: string; seriesPath?: string } = {}
): { command: string; mountDir: string } {
const inputPath = item.file_path;
const isEpisode = item.type === 'Episode';
let mountDir: string;
let relPath: string;
const hostRoot = (isEpisode ? opts.seriesPath : opts.moviesPath)?.replace(/\/$/, '') ?? '';
// Jellyfin always mounts libraries at /movies and /series by convention
const jellyfinPrefix = isEpisode ? '/series' : '/movies';
if (hostRoot) {
mountDir = hostRoot;
if (inputPath.startsWith(jellyfinPrefix + '/')) {
relPath = inputPath.slice(jellyfinPrefix.length); // keeps leading /
} else {
// Path doesn't match the expected prefix — strip 1 component as best effort
const components = inputPath.split('/').filter(Boolean);
relPath = '/' + components.slice(1).join('/');
}
} else {
// No host path configured — fall back to mounting the file's immediate parent directory
const lastSlash = inputPath.lastIndexOf('/');
mountDir = lastSlash >= 0 ? inputPath.slice(0, lastSlash) : '.';
relPath = '/' + (lastSlash >= 0 ? inputPath.slice(lastSlash + 1) : inputPath);
}
const ext = relPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv';
const tmpRelPath = relPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
const workInput = `/work${relPath}`;
const workTmp = `/work${tmpRelPath}`;
const workBasePath = workInput.replace(/\.[^.]+$/, '');
const kept = streams
.map((s) => {
const dec = decisions.find((d) => d.stream_id === s.id);
return dec?.action === 'keep' ? { stream: s, dec } : null;
})
.filter(Boolean) as { stream: MediaStream; dec: StreamDecision }[];
const typeOrder: Record<string, number> = { Video: 0, Audio: 1, Subtitle: 2, Data: 3, EmbeddedImage: 4 };
kept.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.dec.target_index ?? 0) - (b.dec.target_index ?? 0);
});
const maps = buildMaps(streams, kept);
const streamFlags = buildStreamFlags(kept);
// Subtitle extraction uses /work paths so files land in the mounted directory
const extractionOutputs = buildExtractionOutputs(streams, workBasePath);
// The jrottenberg/ffmpeg entrypoint IS ffmpeg — run it directly so no inner
// shell is needed and no nested quoting is required. The mv step runs on the
// host (outside Docker) so it uses the real host paths.
const hostInput = mountDir + relPath;
const hostTmp = mountDir + tmpRelPath;
const parts = [
'docker run --rm',
`-v ${shellQuote(mountDir + ':/work')}`,
'jrottenberg/ffmpeg:latest',
'-y',
'-i', shellQuote(workInput),
...maps,
...streamFlags,
'-c copy',
shellQuote(workTmp),
...extractionOutputs,
'&&',
'mv', shellQuote(hostTmp), shellQuote(hostInput),
];
return { command: parts.join(' '), mountDir };
}
/**
* Build a command that ONLY extracts subtitles to sidecar files
* without modifying the container. Useful when the item is otherwise
@@ -439,54 +335,6 @@ export function buildExtractOnlyCommand(
return ['ffmpeg', '-y', '-i', shellQuote(item.file_path), ...extractionOutputs].join(' ');
}
/**
* Build a Docker command that ONLY extracts subtitles to sidecar files.
*/
export function buildDockerExtractOnlyCommand(
item: MediaItem,
streams: MediaStream[],
opts: { moviesPath?: string; seriesPath?: string } = {}
): { command: string; mountDir: string } | null {
const inputPath = item.file_path;
const isEpisode = item.type === 'Episode';
let mountDir: string;
let relPath: string;
const hostRoot = (isEpisode ? opts.seriesPath : opts.moviesPath)?.replace(/\/$/, '') ?? '';
const jellyfinPrefix = isEpisode ? '/series' : '/movies';
if (hostRoot) {
mountDir = hostRoot;
if (inputPath.startsWith(jellyfinPrefix + '/')) {
relPath = inputPath.slice(jellyfinPrefix.length);
} else {
const components = inputPath.split('/').filter(Boolean);
relPath = '/' + components.slice(1).join('/');
}
} else {
const lastSlash = inputPath.lastIndexOf('/');
mountDir = lastSlash >= 0 ? inputPath.slice(0, lastSlash) : '.';
relPath = '/' + (lastSlash >= 0 ? inputPath.slice(lastSlash + 1) : inputPath);
}
const workInput = `/work${relPath}`;
const workBasePath = workInput.replace(/\.[^.]+$/, '');
const extractionOutputs = buildExtractionOutputs(streams, workBasePath);
if (extractionOutputs.length === 0) return null;
const parts = [
'docker run --rm',
`-v ${shellQuote(mountDir + ':/work')}`,
'jrottenberg/ffmpeg:latest',
'-y',
'-i', shellQuote(workInput),
...extractionOutputs,
];
return { command: parts.join(' '), mountDir };
}
/** Safely quote a path for shell usage. */
export function shellQuote(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`;

View File

@@ -97,6 +97,8 @@ export interface Node {
private_key: string;
ffmpeg_path: string;
work_dir: string;
movies_path: string;
series_path: string;
status: 'unknown' | 'ok' | 'error';
last_checked_at: string | null;
created_at: string;

View File

@@ -52,8 +52,8 @@ export function NodesPage() {
</div>
<p className="text-gray-500 mb-4">
Remote nodes run FFmpeg over SSH on shared storage. The path to the media file must be
identical on both this server and the remote node.
Remote nodes run FFmpeg over SSH. If the media is mounted at a different path on the
remote node, set the Movies/Series path fields to translate <code className="font-mono bg-gray-100 px-1 rounded">/movies/</code> and <code className="font-mono bg-gray-100 px-1 rounded">/series/</code> to the node's mount points.
</p>
{/* Add form */}
@@ -86,6 +86,16 @@ export function NodesPage() {
Work directory
<Input name="work_dir" defaultValue="/tmp" className="mt-0.5" />
</label>
<label className="block text-sm text-gray-700 mb-0.5">
Movies path
<Input name="movies_path" placeholder="/mnt/media/movies" className="mt-0.5" />
<small className="text-xs text-gray-500 mt-0.5 block">Remote mount point for movies (leave empty if same as container)</small>
</label>
<label className="block text-sm text-gray-700 mb-0.5">
Series path
<Input name="series_path" placeholder="/mnt/media/series" className="mt-0.5" />
<small className="text-xs text-gray-500 mt-0.5 block">Remote mount point for series (leave empty if same as container)</small>
</label>
</div>
<label className="block text-sm text-gray-700 mb-0.5">
Private key (PEM)
@@ -110,7 +120,7 @@ export function NodesPage() {
<table className="w-full border-collapse text-[0.82rem]">
<thead>
<tr>
{['Name', 'Host', 'Port', 'User', 'FFmpeg', 'Status', 'Actions'].map((h) => (
{['Name', 'Host', 'Port', 'User', 'FFmpeg', 'Movies', 'Series', 'Status', 'Actions'].map((h) => (
<th key={h} className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">{h}</th>
))}
</tr>
@@ -123,6 +133,8 @@ export function NodesPage() {
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{node.port}</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{node.username}</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{node.ffmpeg_path}</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{node.movies_path || ''}</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{node.series_path || ''}</td>
<td className="py-1.5 px-2 border-b border-gray-100">
<Badge variant={nodeStatusVariant(node.status)}>{node.status}</Badge>
</td>

View File

@@ -13,7 +13,7 @@ import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '~/share
interface DetailData {
item: MediaItem; streams: MediaStream[];
plan: ReviewPlan | null; decisions: StreamDecision[];
command: string | null; dockerCommand: string | null; dockerMountDir: string | null;
command: string | null;
}
// ─── Utilities ────────────────────────────────────────────────────────────────
@@ -27,13 +27,6 @@ function formatBytes(bytes: number): string {
function effectiveLabel(s: MediaStream, dec: StreamDecision | undefined): string {
if (dec?.custom_title) return dec.custom_title;
if (s.type === 'Subtitle') {
if (!s.language) return '';
const base = langName(s.language);
if (s.is_forced) return `${base} (Forced)`;
if (s.is_hearing_impaired) return `${base} (CC)`;
return base;
}
if (s.title) return s.title;
if (s.type === 'Audio' && s.channels) return `${s.channels}ch ${s.channel_layout ?? ''}`.trim();
return s.language ? langName(s.language) : '';
@@ -44,9 +37,6 @@ function effectiveLabel(s: MediaStream, dec: StreamDecision | undefined): string
const STREAM_SECTIONS = [
{ type: 'Video', label: 'Video' },
{ type: 'Audio', label: 'Audio' },
{ type: 'Subtitle', label: 'Subtitles — all extracted to sidecar files' },
{ type: 'Data', label: 'Data' },
{ type: 'EmbeddedImage', label: 'Embedded Images' },
];
const TYPE_ORDER: Record<string, number> = { Video: 0, Audio: 1, Subtitle: 2, Data: 3, EmbeddedImage: 4 };
@@ -109,21 +99,19 @@ function StreamTable({ data, onUpdate }: StreamTableProps) {
...group.map((s) => {
const dec = decisions.find((d) => d.stream_id === s.id);
const action = dec?.action ?? 'keep';
const isSub = s.type === 'Subtitle';
const isAudio = s.type === 'Audio';
const outputNum = outIdx.get(s.id);
const lbl = effectiveLabel(s, dec);
const origTitle = s.title;
const lang = langName(s.language);
// Only audio streams can be edited; subtitles are always extracted
const isEditable = plan?.status === 'pending' && isAudio;
const rowBg = isSub ? 'bg-sky-50' : action === 'keep' ? 'bg-green-50' : 'bg-red-50';
const rowBg = action === 'keep' ? 'bg-green-50' : 'bg-red-50';
return (
<tr key={s.id} className={rowBg}>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">
{isSub ? <span className="text-gray-400"></span> : outputNum !== undefined ? outputNum : <span className="text-gray-400"></span>}
{outputNum !== undefined ? outputNum : <span className="text-gray-400"></span>}
</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.codec ?? '—'}</td>
<td className="py-1.5 px-2 border-b border-gray-100">
@@ -150,11 +138,7 @@ function StreamTable({ data, onUpdate }: StreamTableProps) {
</span>
</td>
<td className="py-1.5 px-2 border-b border-gray-100">
{isSub ? (
<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
</span>
) : plan?.status === 'pending' && (isAudio) ? (
{plan?.status === 'pending' && isAudio ? (
<button
type="button"
onClick={() => toggleStream(s.id, action)}
@@ -209,6 +193,7 @@ export function AudioDetailPage() {
};
const approve = async () => { await api.post(`/api/review/${id}/approve`); load(); };
const unapprove = async () => { await api.post(`/api/review/${id}/unapprove`); load(); };
const skip = async () => { await api.post(`/api/review/${id}/skip`); load(); };
const unskip = async () => { await api.post(`/api/review/${id}/unskip`); load(); };
const rescan = async () => {
@@ -220,7 +205,7 @@ export function AudioDetailPage() {
if (loading) return <div className="text-gray-400 py-8 text-center">Loading</div>;
if (!data) return <Alert variant="error">Item not found.</Alert>;
const { item, plan, command, dockerCommand, dockerMountDir } = data;
const { item, plan, command } = data;
const statusKey = plan?.is_noop ? 'noop' : (plan?.status ?? 'pending');
return (
@@ -278,7 +263,7 @@ export function AudioDetailPage() {
{/* FFmpeg command */}
{command && (
<div className="mt-6">
<div className="text-gray-400 text-[0.75rem] uppercase tracking-[0.05em] mb-1">FFmpeg command (audio + subtitle extraction)</div>
<div className="text-gray-400 text-[0.75rem] uppercase tracking-[0.05em] mb-1">FFmpeg command (audio remux)</div>
<textarea
readOnly
rows={3}
@@ -288,21 +273,6 @@ export function AudioDetailPage() {
</div>
)}
{dockerCommand && (
<div className="mt-3">
<div className="flex items-baseline gap-2 mb-1 flex-wrap">
<span className="text-gray-400 text-[0.75rem] uppercase tracking-[0.05em]">Docker (fallback)</span>
<span className="text-gray-400 text-[0.7rem]"> mount: <code className="font-mono">{dockerMountDir}:/work</code></span>
</div>
<textarea
readOnly
rows={3}
value={dockerCommand}
className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#9cdcfe] p-3 rounded w-full resize-y border-0 min-h-10"
/>
</div>
)}
{/* Actions */}
{plan?.status === 'pending' && !plan.is_noop && (
<div className="flex gap-2 mt-6">
@@ -310,6 +280,11 @@ export function AudioDetailPage() {
<Button variant="secondary" onClick={skip}>Skip</Button>
</div>
)}
{plan?.status === 'approved' && (
<div className="mt-6">
<Button variant="secondary" onClick={unapprove}>Unapprove</Button>
</div>
)}
{plan?.status === 'skipped' && (
<div className="mt-6">
<Button variant="secondary" onClick={unskip}>Unskip</Button>

View File

@@ -134,12 +134,6 @@ export function SetupPage() {
await api.post('/api/setup/subtitle-languages', { langs });
};
const savePaths = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
await api.post('/api/setup/paths', { movies_path: fd.get('movies_path'), series_path: fd.get('series_path') });
};
const clearScan = async () => {
if (!confirm('Delete all scanned data? This will remove all media items, stream decisions, review plans, and jobs. This cannot be undone.')) return;
await api.post('/api/setup/clear-scan');
@@ -178,29 +172,6 @@ export function SetupPage() {
onSave={saveSonarr}
/>
{/* Media paths */}
<SectionCard title={<span className="flex items-center gap-2">Media Paths <EnvBadge envVar="MOVIES_PATH" locked={locked.has('movies_path')} /> <EnvBadge envVar="SERIES_PATH" locked={locked.has('series_path')} /></span>} subtitle={
<>
Host paths where your media lives on the machine running the Docker command.
Jellyfin always exposes libraries at <code className="font-mono bg-gray-100 px-1 rounded">/movies</code> and <code className="font-mono bg-gray-100 px-1 rounded">/series</code>,
so only the host-side path is needed to generate a correct Docker command.
</>
}>
<form onSubmit={savePaths}>
<label className="block text-sm text-gray-700 mb-1">
Movies root path
<LockedInput locked={locked.has('movies_path')} name="movies_path" defaultValue={cfg.movies_path ?? ''} placeholder="/mnt/user/storage/Movies" className="mt-0.5 max-w-sm" />
<small className="text-xs text-gray-500 mt-0.5 block">Host directory mounted as <code className="font-mono">/movies</code> inside Jellyfin</small>
</label>
<label className="block text-sm text-gray-700 mb-1 mt-3">
Series root path
<LockedInput locked={locked.has('series_path')} name="series_path" defaultValue={cfg.series_path ?? ''} placeholder="/mnt/user/storage/Series" className="mt-0.5 max-w-sm" />
<small className="text-xs text-gray-500 mt-0.5 block">Host directory mounted as <code className="font-mono">/series</code> inside Jellyfin</small>
</label>
<Button type="submit" className="mt-3">Save</Button>
</form>
</SectionCard>
{/* Subtitle languages */}
<SectionCard
title={

View File

@@ -18,8 +18,6 @@ interface DetailData {
decisions: StreamDecision[];
subs_extracted: number;
extractCommand: string | null;
dockerCommand: string | null;
dockerMountDir: string | null;
}
// ─── Utilities ────────────────────────────────────────────────────────────────
@@ -232,7 +230,7 @@ export function SubtitleDetailPage() {
if (loading) return <div className="text-gray-400 py-8 text-center">Loading</div>;
if (!data) return <Alert variant="error">Item not found.</Alert>;
const { item, subtitleStreams, files, decisions, subs_extracted, extractCommand, dockerCommand, dockerMountDir } = data;
const { item, subtitleStreams, files, decisions, subs_extracted, extractCommand } = data;
const hasContainerSubs = subtitleStreams.length > 0;
const editable = !subs_extracted && hasContainerSubs;
@@ -299,21 +297,6 @@ export function SubtitleDetailPage() {
</div>
)}
{dockerCommand && (
<div className="mt-3">
<div className="flex items-baseline gap-2 mb-1 flex-wrap">
<span className="text-gray-400 text-[0.75rem] uppercase tracking-[0.05em]">Docker (fallback)</span>
<span className="text-gray-400 text-[0.7rem]"> mount: <code className="font-mono">{dockerMountDir}:/work</code></span>
</div>
<textarea
readOnly
rows={3}
value={dockerCommand}
className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#9cdcfe] p-3 rounded w-full resize-y border-0 min-h-10"
/>
</div>
)}
{/* Actions */}
{hasContainerSubs && !subs_extracted && (
<div className="flex gap-2 mt-6">

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
import { api } from '~/shared/lib/api';
import { Badge } from '~/shared/components/ui/badge';
import { Button } from '~/shared/components/ui/button';
import { langName } from '~/shared/lib/lang';
import type { MediaItem } from '~/shared/lib/types';
@@ -33,10 +34,23 @@ export function SubtitleListPage() {
const navigate = useNavigate();
const [data, setData] = useState<SubtitleListData | null>(null);
const [loading, setLoading] = useState(true);
const [extracting, setExtracting] = useState(false);
const [extractResult, setExtractResult] = useState('');
useEffect(() => {
api.get<SubtitleListData>(`/api/subtitles?filter=${filter}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
}, [filter]);
const load = () => api.get<SubtitleListData>(`/api/subtitles?filter=${filter}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
useEffect(() => { load(); }, [filter]);
const extractAll = async () => {
setExtracting(true);
setExtractResult('');
try {
const r = await api.post<{ ok: boolean; queued: number }>('/api/subtitles/extract-all');
setExtractResult(`Queued ${r.queued} extraction job${r.queued !== 1 ? 's' : ''}.`);
load();
} catch (e) { setExtractResult(`Error: ${e}`); }
setExtracting(false);
};
if (loading) return <div className="text-gray-400 py-8 text-center">Loading</div>;
if (!data) return <div className="text-red-600">Failed to load.</div>;
@@ -45,7 +59,13 @@ export function SubtitleListPage() {
return (
<div>
<h1 className="text-xl font-bold m-0 mb-4">Subtitle Manager</h1>
<div className="flex items-center gap-3 mb-4">
<h1 className="text-xl font-bold m-0">Subtitle Manager</h1>
<Button size="sm" onClick={extractAll} disabled={extracting || (data?.totalCounts.not_extracted ?? 0) === 0}>
{extracting ? 'Queuing…' : 'Extract All Pending'}
</Button>
{extractResult && <span className="text-sm text-gray-500">{extractResult}</span>}
</div>
{/* Filter tabs */}
<div className="flex gap-1 flex-wrap mb-3 items-center">

View File

@@ -95,6 +95,8 @@ export interface Node {
private_key: string;
ffmpeg_path: string;
work_dir: string;
movies_path: string;
series_path: string;
status: string;
last_checked_at: string | null;
}

40
unraid-template.xml Normal file
View File

@@ -0,0 +1,40 @@
<?xml version="1.0"?>
<Container version="2">
<Name>netfelix-audio-fix</Name>
<Repository>git.felixfoertsch.de/felixfoertsch/netfelix-audio-fix:latest</Repository>
<Registry>https://git.felixfoertsch.de/felixfoertsch/-/packages/container/netfelix-audio-fix/latest</Registry>
<Network>traefik</Network>
<MyIP/>
<Shell>sh</Shell>
<Privileged>false</Privileged>
<Support>https://git.felixfoertsch.de/felixfoertsch/netfelix-audio-fix</Support>
<Project>https://git.felixfoertsch.de/felixfoertsch/netfelix-audio-fix</Project>
<Overview>Scan a Jellyfin library, review which audio/subtitle tracks to keep or remove, then execute FFmpeg to strip/reorder streams.</Overview>
<Category>MediaApp:Video Tools:Utilities</Category>
<WebUI>http://[IP]:[PORT:3000]/</WebUI>
<TemplateURL/>
<Icon/>
<ExtraParams/>
<PostArgs/>
<CPUset/>
<DonateText/>
<DonateLink/>
<Requires/>
<Config Name="WebUI" Target="3000" Default="3000" Mode="tcp" Description="Web interface port" Type="Port" Display="always" Required="true" Mask="false">3000</Config>
<Config Name="Data" Target="/data/" Default="/mnt/user/appdata/netfelix-audio-fix/" Mode="rw" Description="SQLite database and application data" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/netfelix-audio-fix/</Config>
<Config Name="Jellyfin URL" Target="JELLYFIN_URL" Default="" Mode="" Description="Jellyfin server URL (e.g. http://jellyfin:8096)" Type="Variable" Display="always" Required="true" Mask="false"></Config>
<Config Name="Jellyfin API Key" Target="JELLYFIN_API_KEY" Default="" Mode="" Description="Jellyfin API key (admin key recommended)" Type="Variable" Display="always" Required="true" Mask="true"></Config>
<Config Name="Jellyfin User ID" Target="JELLYFIN_USER_ID" Default="" Mode="" Description="Jellyfin user ID (optional, auto-detected if omitted)" Type="Variable" Display="always" Required="false" Mask="false"></Config>
<Config Name="Radarr Enabled" Target="RADARR_ENABLED" Default="1" Mode="" Description="Enable Radarr for movie language detection (1 or 0)" Type="Variable" Display="always" Required="false" Mask="false">1</Config>
<Config Name="Radarr URL" Target="RADARR_URL" Default="" Mode="" Description="Radarr server URL (e.g. http://radarr:7878)" Type="Variable" Display="always" Required="false" Mask="false"></Config>
<Config Name="Radarr API Key" Target="RADARR_API_KEY" Default="" Mode="" Description="Radarr API key" Type="Variable" Display="always" Required="false" Mask="true"></Config>
<Config Name="Sonarr Enabled" Target="SONARR_ENABLED" Default="1" Mode="" Description="Enable Sonarr for episode language detection (1 or 0)" Type="Variable" Display="always" Required="false" Mask="false">1</Config>
<Config Name="Sonarr URL" Target="SONARR_URL" Default="" Mode="" Description="Sonarr server URL (e.g. http://sonarr:8989)" Type="Variable" Display="always" Required="false" Mask="false"></Config>
<Config Name="Sonarr API Key" Target="SONARR_API_KEY" Default="" Mode="" Description="Sonarr API key" Type="Variable" Display="always" Required="false" Mask="true"></Config>
<Config Name="Subtitle Languages" Target="SUBTITLE_LANGUAGES" Default="eng,deu" Mode="" Description="Subtitle languages to keep (comma-separated ISO 639-2 codes)" Type="Variable" Display="always" Required="false" Mask="false">eng,deu</Config>
<Config Name="Movies" Target="/movies/" Default="/mnt/user/media/movies/" Mode="rw" Description="Movies library (mounted into the container for FFmpeg access)" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/media/movies/</Config>
<Config Name="Series" Target="/series/" Default="/mnt/user/media/series/" Mode="rw" Description="Series library (mounted into the container for FFmpeg access)" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/media/series/</Config>
<Config Name="DATA_DIR" Target="DATA_DIR" Default="/data/" Mode="" Description="Data directory inside container" Type="Variable" Display="advanced" Required="false" Mask="false">/data/</Config>
<Config Name="PORT" Target="PORT" Default="3000" Mode="" Description="Server port inside container" Type="Variable" Display="advanced" Required="false" Mask="false">3000</Config>
<TailscaleStateDir/>
</Container>