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/ .claude/
.env* .env*
*.md *.md
*.xml

View File

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

View File

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

View File

@@ -177,7 +177,11 @@ async function runJob(job: Job): Promise<void> {
if (job.node_id) { if (job.node_id) {
const node = db.prepare('SELECT * FROM nodes WHERE id = ?').get(job.node_id) as Node | undefined; 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`); 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 { } else {
const proc = Bun.spawn(['sh', '-c', job.command], { stdout: 'pipe', stderr: 'pipe' }); const proc = Bun.spawn(['sh', '-c', job.command], { stdout: 'pipe', stderr: 'pipe' });
const readStream = async (readable: ReadableStream<Uint8Array>, prefix = '') => { const readStream = async (readable: ReadableStream<Uint8Array>, prefix = '') => {

View File

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

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { getDb, getConfig, getAllConfig } from '../db/index'; import { getDb, getConfig, getAllConfig } from '../db/index';
import { analyzeItem } from '../services/analyzer'; 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 { normalizeLanguage, getItem, refreshItem, mapStream } from '../services/jellyfin';
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '../types'; 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) { function loadItemDetail(db: ReturnType<typeof getDb>, itemId: number) {
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(itemId) as MediaItem | undefined; const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(itemId) as MediaItem | undefined;
if (!item) return { item: null, streams: [], plan: null, decisions: [], command: null, 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 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 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 decisions = plan ? db.prepare('SELECT * FROM stream_decisions WHERE plan_id = ?').all(plan.id) as StreamDecision[] : [];
const command = plan && !plan.is_noop ? buildCommand(item, streams, decisions) : null; const command = plan && !plan.is_noop ? buildCommand(item, streams, decisions) : null;
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 { function reanalyze(db: ReturnType<typeof getDb>, itemId: number): void {
@@ -317,6 +309,23 @@ app.post('/:id/approve', (c) => {
return c.json({ ok: true }); 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 ─────────────────────────────────────────────────────────── // ─── Skip / Unskip ───────────────────────────────────────────────────────────
app.post('/:id/skip', (c) => { app.post('/:id/skip', (c) => {

View File

@@ -83,15 +83,6 @@ app.post('/subtitle-languages', async (c) => {
return c.json({ ok: true }); 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) => { app.post('/clear-scan', (c) => {
const db = getDb(); const db = getDb();
db.prepare('DELETE FROM media_items').run(); db.prepare('DELETE FROM media_items').run();

View File

@@ -1,6 +1,6 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { getDb, getAllConfig } from '../db/index'; 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 { normalizeLanguage, getItem, refreshItem, mapStream } from '../services/jellyfin';
import type { MediaItem, MediaStream, SubtitleFile, ReviewPlan, StreamDecision } from '../types'; import type { MediaItem, MediaStream, SubtitleFile, ReviewPlan, StreamDecision } from '../types';
import { unlinkSync } from 'node:fs'; 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 allStreams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(itemId) as MediaStream[];
const extractCommand = buildExtractOnlyCommand(item, allStreams); const extractCommand = buildExtractOnlyCommand(item, allStreams);
const cfg = getAllConfig();
const dockerResult = buildDockerExtractOnlyCommand(item, allStreams, { moviesPath: cfg.movies_path || undefined, seriesPath: cfg.series_path || undefined });
return { return {
item, item,
@@ -32,8 +30,6 @@ function loadDetail(db: ReturnType<typeof getDb>, itemId: number) {
decisions, decisions,
subs_extracted: plan?.subs_extracted ?? 0, subs_extracted: plan?.subs_extracted ?? 0,
extractCommand, 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); 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 ───────────────────────────────────────────────────────────────── // ─── Extract ─────────────────────────────────────────────────────────────────
app.post('/:id/extract', (c) => { app.post('/:id/extract', (c) => {

View File

@@ -22,8 +22,6 @@ const ENV_MAP: Record<string, string> = {
sonarr_api_key: 'SONARR_API_KEY', sonarr_api_key: 'SONARR_API_KEY',
sonarr_enabled: 'SONARR_ENABLED', sonarr_enabled: 'SONARR_ENABLED',
subtitle_languages: 'SUBTITLE_LANGUAGES', subtitle_languages: 'SUBTITLE_LANGUAGES',
movies_path: 'MOVIES_PATH',
series_path: 'SERIES_PATH',
}; };
/** Read a config key from environment variables (returns null if not set). */ /** 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 // 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 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 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); seedDefaults(_db);
return _db; return _db;
} }

View File

@@ -16,6 +16,8 @@ CREATE TABLE IF NOT EXISTS nodes (
private_key TEXT NOT NULL, private_key TEXT NOT NULL,
ffmpeg_path TEXT NOT NULL DEFAULT 'ffmpeg', ffmpeg_path TEXT NOT NULL DEFAULT 'ffmpeg',
work_dir TEXT NOT NULL DEFAULT '/tmp', 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', status TEXT NOT NULL DEFAULT 'unknown',
last_checked_at TEXT, last_checked_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
@@ -124,6 +126,4 @@ export const DEFAULT_CONFIG: Record<string, string> = {
sonarr_enabled: '0', sonarr_enabled: '0',
subtitle_languages: JSON.stringify(['eng', 'deu', 'spa']), subtitle_languages: JSON.stringify(['eng', 'deu', 'spa']),
scan_running: '0', 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. * - 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( function buildStreamFlags(
kept: { stream: MediaStream; dec: StreamDecision }[] kept: { stream: MediaStream; dec: StreamDecision }[]
): string[] { ): string[] {
const audioKept = kept.filter((k) => k.stream.type === 'Audio'); const audioKept = kept.filter((k) => k.stream.type === 'Audio');
const subKept = kept.filter((k) => k.stream.type === 'Subtitle');
const args: string[] = []; const args: string[] = [];
// Disposition: first audio = default, rest = clear // Disposition: first audio = default, rest = clear
@@ -224,12 +223,6 @@ function buildStreamFlags(
if (title) args.push(`-metadata:s:a:${i}`, `title=${shellQuote(title)}`); 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; return args;
} }
@@ -264,11 +257,9 @@ export function buildCommand(
const inputPath = item.file_path; const inputPath = item.file_path;
const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv'; const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv';
const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`); const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
const basePath = inputPath.replace(/\.[^.]+$/, '');
const maps = buildMaps(streams, kept); const maps = buildMaps(streams, kept);
const streamFlags = buildStreamFlags(kept); const streamFlags = buildStreamFlags(kept);
const extractionOutputs = buildExtractionOutputs(streams, basePath);
const parts: string[] = [ const parts: string[] = [
'ffmpeg', 'ffmpeg',
@@ -278,7 +269,6 @@ export function buildCommand(
...streamFlags, ...streamFlags,
'-c copy', '-c copy',
shellQuote(tmpPath), shellQuote(tmpPath),
...extractionOutputs,
'&&', '&&',
'mv', shellQuote(tmpPath), shellQuote(inputPath), 'mv', shellQuote(tmpPath), shellQuote(inputPath),
]; ];
@@ -298,7 +288,6 @@ export function buildMkvConvertCommand(
const inputPath = item.file_path; const inputPath = item.file_path;
const outputPath = inputPath.replace(/\.[^.]+$/, '.mkv'); const outputPath = inputPath.replace(/\.[^.]+$/, '.mkv');
const tmpPath = inputPath.replace(/\.[^.]+$/, '.tmp.mkv'); const tmpPath = inputPath.replace(/\.[^.]+$/, '.tmp.mkv');
const basePath = outputPath.replace(/\.[^.]+$/, '');
const kept = streams const kept = streams
.map((s) => { .map((s) => {
@@ -317,7 +306,6 @@ export function buildMkvConvertCommand(
const maps = buildMaps(streams, kept); const maps = buildMaps(streams, kept);
const streamFlags = buildStreamFlags(kept); const streamFlags = buildStreamFlags(kept);
const extractionOutputs = buildExtractionOutputs(streams, basePath);
return [ return [
'ffmpeg', '-y', 'ffmpeg', '-y',
@@ -327,103 +315,11 @@ export function buildMkvConvertCommand(
'-c copy', '-c copy',
'-f matroska', '-f matroska',
shellQuote(tmpPath), shellQuote(tmpPath),
...extractionOutputs,
'&&', '&&',
'mv', shellQuote(tmpPath), shellQuote(outputPath), 'mv', shellQuote(tmpPath), shellQuote(outputPath),
].join(' '); ].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 * Build a command that ONLY extracts subtitles to sidecar files
* without modifying the container. Useful when the item is otherwise * 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(' '); 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. */ /** Safely quote a path for shell usage. */
export function shellQuote(s: string): string { export function shellQuote(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`; return `'${s.replace(/'/g, "'\\''")}'`;

View File

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

View File

@@ -52,8 +52,8 @@ export function NodesPage() {
</div> </div>
<p className="text-gray-500 mb-4"> <p className="text-gray-500 mb-4">
Remote nodes run FFmpeg over SSH on shared storage. The path to the media file must be Remote nodes run FFmpeg over SSH. If the media is mounted at a different path on the
identical on both this server and the remote node. 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> </p>
{/* Add form */} {/* Add form */}
@@ -86,6 +86,16 @@ export function NodesPage() {
Work directory Work directory
<Input name="work_dir" defaultValue="/tmp" className="mt-0.5" /> <Input name="work_dir" defaultValue="/tmp" className="mt-0.5" />
</label> </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> </div>
<label className="block text-sm text-gray-700 mb-0.5"> <label className="block text-sm text-gray-700 mb-0.5">
Private key (PEM) Private key (PEM)
@@ -110,7 +120,7 @@ export function NodesPage() {
<table className="w-full border-collapse text-[0.82rem]"> <table className="w-full border-collapse text-[0.82rem]">
<thead> <thead>
<tr> <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> <th key={h} className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">{h}</th>
))} ))}
</tr> </tr>
@@ -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.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.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.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"> <td className="py-1.5 px-2 border-b border-gray-100">
<Badge variant={nodeStatusVariant(node.status)}>{node.status}</Badge> <Badge variant={nodeStatusVariant(node.status)}>{node.status}</Badge>
</td> </td>

View File

@@ -13,7 +13,7 @@ import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '~/share
interface DetailData { interface DetailData {
item: MediaItem; streams: MediaStream[]; item: MediaItem; streams: MediaStream[];
plan: ReviewPlan | null; decisions: StreamDecision[]; plan: ReviewPlan | null; decisions: StreamDecision[];
command: string | null; dockerCommand: string | null; dockerMountDir: string | null; command: string | null;
} }
// ─── Utilities ──────────────────────────────────────────────────────────────── // ─── Utilities ────────────────────────────────────────────────────────────────
@@ -27,13 +27,6 @@ function formatBytes(bytes: number): string {
function effectiveLabel(s: MediaStream, dec: StreamDecision | undefined): string { function effectiveLabel(s: MediaStream, dec: StreamDecision | undefined): string {
if (dec?.custom_title) return dec.custom_title; if (dec?.custom_title) return dec.custom_title;
if (s.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.title) return s.title;
if (s.type === 'Audio' && s.channels) return `${s.channels}ch ${s.channel_layout ?? ''}`.trim(); if (s.type === 'Audio' && s.channels) return `${s.channels}ch ${s.channel_layout ?? ''}`.trim();
return s.language ? langName(s.language) : ''; return s.language ? langName(s.language) : '';
@@ -44,9 +37,6 @@ function effectiveLabel(s: MediaStream, dec: StreamDecision | undefined): string
const STREAM_SECTIONS = [ const STREAM_SECTIONS = [
{ type: 'Video', label: 'Video' }, { type: 'Video', label: 'Video' },
{ type: 'Audio', label: 'Audio' }, { 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 }; 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) => { ...group.map((s) => {
const dec = decisions.find((d) => d.stream_id === s.id); const dec = decisions.find((d) => d.stream_id === s.id);
const action = dec?.action ?? 'keep'; const action = dec?.action ?? 'keep';
const isSub = s.type === 'Subtitle';
const isAudio = s.type === 'Audio'; const isAudio = s.type === 'Audio';
const outputNum = outIdx.get(s.id); const outputNum = outIdx.get(s.id);
const lbl = effectiveLabel(s, dec); const lbl = effectiveLabel(s, dec);
const origTitle = s.title; const origTitle = s.title;
const lang = langName(s.language); const lang = langName(s.language);
// Only audio streams can be edited; subtitles are always extracted
const isEditable = plan?.status === 'pending' && isAudio; 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 ( return (
<tr key={s.id} className={rowBg}> <tr key={s.id} className={rowBg}>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs"> <td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">
{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>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.codec ?? '—'}</td> <td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.codec ?? '—'}</td>
<td className="py-1.5 px-2 border-b border-gray-100"> <td className="py-1.5 px-2 border-b border-gray-100">
@@ -150,11 +138,7 @@ function StreamTable({ data, onUpdate }: StreamTableProps) {
</span> </span>
</td> </td>
<td className="py-1.5 px-2 border-b border-gray-100"> <td className="py-1.5 px-2 border-b border-gray-100">
{isSub ? ( {plan?.status === 'pending' && isAudio ? (
<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) ? (
<button <button
type="button" type="button"
onClick={() => toggleStream(s.id, action)} onClick={() => toggleStream(s.id, action)}
@@ -209,6 +193,7 @@ export function AudioDetailPage() {
}; };
const approve = async () => { await api.post(`/api/review/${id}/approve`); load(); }; const approve = async () => { await api.post(`/api/review/${id}/approve`); load(); };
const unapprove = async () => { await api.post(`/api/review/${id}/unapprove`); load(); };
const skip = async () => { await api.post(`/api/review/${id}/skip`); load(); }; const skip = async () => { await api.post(`/api/review/${id}/skip`); load(); };
const unskip = async () => { await api.post(`/api/review/${id}/unskip`); load(); }; const unskip = async () => { await api.post(`/api/review/${id}/unskip`); load(); };
const rescan = async () => { 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 (loading) return <div className="text-gray-400 py-8 text-center">Loading</div>;
if (!data) return <Alert variant="error">Item not found.</Alert>; if (!data) return <Alert variant="error">Item not found.</Alert>;
const { item, plan, command, dockerCommand, dockerMountDir } = data; const { item, plan, command } = data;
const statusKey = plan?.is_noop ? 'noop' : (plan?.status ?? 'pending'); const statusKey = plan?.is_noop ? 'noop' : (plan?.status ?? 'pending');
return ( return (
@@ -278,7 +263,7 @@ export function AudioDetailPage() {
{/* FFmpeg command */} {/* FFmpeg command */}
{command && ( {command && (
<div className="mt-6"> <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 <textarea
readOnly readOnly
rows={3} rows={3}
@@ -288,21 +273,6 @@ export function AudioDetailPage() {
</div> </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 */} {/* Actions */}
{plan?.status === 'pending' && !plan.is_noop && ( {plan?.status === 'pending' && !plan.is_noop && (
<div className="flex gap-2 mt-6"> <div className="flex gap-2 mt-6">
@@ -310,6 +280,11 @@ export function AudioDetailPage() {
<Button variant="secondary" onClick={skip}>Skip</Button> <Button variant="secondary" onClick={skip}>Skip</Button>
</div> </div>
)} )}
{plan?.status === 'approved' && (
<div className="mt-6">
<Button variant="secondary" onClick={unapprove}>Unapprove</Button>
</div>
)}
{plan?.status === 'skipped' && ( {plan?.status === 'skipped' && (
<div className="mt-6"> <div className="mt-6">
<Button variant="secondary" onClick={unskip}>Unskip</Button> <Button variant="secondary" onClick={unskip}>Unskip</Button>

View File

@@ -134,12 +134,6 @@ export function SetupPage() {
await api.post('/api/setup/subtitle-languages', { langs }); 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 () => { const clearScan = async () => {
if (!confirm('Delete all scanned data? This will remove all media items, stream decisions, review plans, and jobs. This cannot be undone.')) return; if (!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'); await api.post('/api/setup/clear-scan');
@@ -178,29 +172,6 @@ export function SetupPage() {
onSave={saveSonarr} 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 */} {/* Subtitle languages */}
<SectionCard <SectionCard
title={ title={

View File

@@ -18,8 +18,6 @@ interface DetailData {
decisions: StreamDecision[]; decisions: StreamDecision[];
subs_extracted: number; subs_extracted: number;
extractCommand: string | null; extractCommand: string | null;
dockerCommand: string | null;
dockerMountDir: string | null;
} }
// ─── Utilities ──────────────────────────────────────────────────────────────── // ─── Utilities ────────────────────────────────────────────────────────────────
@@ -232,7 +230,7 @@ export function SubtitleDetailPage() {
if (loading) return <div className="text-gray-400 py-8 text-center">Loading</div>; if (loading) return <div className="text-gray-400 py-8 text-center">Loading</div>;
if (!data) return <Alert variant="error">Item not found.</Alert>; if (!data) return <Alert variant="error">Item not found.</Alert>;
const { item, subtitleStreams, files, decisions, subs_extracted, extractCommand, dockerCommand, dockerMountDir } = data; const { item, subtitleStreams, files, decisions, subs_extracted, extractCommand } = data;
const hasContainerSubs = subtitleStreams.length > 0; const hasContainerSubs = subtitleStreams.length > 0;
const editable = !subs_extracted && hasContainerSubs; const editable = !subs_extracted && hasContainerSubs;
@@ -299,21 +297,6 @@ export function SubtitleDetailPage() {
</div> </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 */} {/* Actions */}
{hasContainerSubs && !subs_extracted && ( {hasContainerSubs && !subs_extracted && (
<div className="flex gap-2 mt-6"> <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 { Link, useNavigate, useSearch } from '@tanstack/react-router';
import { api } from '~/shared/lib/api'; import { api } from '~/shared/lib/api';
import { Badge } from '~/shared/components/ui/badge'; import { Badge } from '~/shared/components/ui/badge';
import { Button } from '~/shared/components/ui/button';
import { langName } from '~/shared/lib/lang'; import { langName } from '~/shared/lib/lang';
import type { MediaItem } from '~/shared/lib/types'; import type { MediaItem } from '~/shared/lib/types';
@@ -33,10 +34,23 @@ export function SubtitleListPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [data, setData] = useState<SubtitleListData | null>(null); const [data, setData] = useState<SubtitleListData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [extracting, setExtracting] = useState(false);
const [extractResult, setExtractResult] = useState('');
useEffect(() => { const load = () => api.get<SubtitleListData>(`/api/subtitles?filter=${filter}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
api.get<SubtitleListData>(`/api/subtitles?filter=${filter}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
}, [filter]); 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 (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>; if (!data) return <div className="text-red-600">Failed to load.</div>;
@@ -45,7 +59,13 @@ export function SubtitleListPage() {
return ( return (
<div> <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 */} {/* Filter tabs */}
<div className="flex gap-1 flex-wrap mb-3 items-center"> <div className="flex gap-1 flex-wrap mb-3 items-center">

View File

@@ -95,6 +95,8 @@ export interface Node {
private_key: string; private_key: string;
ffmpeg_path: string; ffmpeg_path: string;
work_dir: string; work_dir: string;
movies_path: string;
series_path: string;
status: string; status: string;
last_checked_at: string | null; 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>