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
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:
@@ -6,3 +6,4 @@ data/
|
||||
.claude/
|
||||
.env*
|
||||
*.md
|
||||
*.xml
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = '') => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: '',
|
||||
};
|
||||
|
||||
@@ -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, "'\\''")}'`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
40
unraid-template.xml
Normal 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>
|
||||
Reference in New Issue
Block a user