From d5f4afd26b2793e0563ec24772556569f36941d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Wed, 4 Mar 2026 16:48:00 +0100 Subject: [PATCH] split audio/subtitle concerns, remove docker-in-docker, add per-node path mapping - 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 --- .dockerignore | 1 + Dockerfile | 9 +- docker-compose.yml | 6 +- server/api/execute.ts | 6 +- server/api/nodes.ts | 14 +- server/api/review.ts | 31 ++-- server/api/setup.ts | 9 - server/api/subtitles.ts | 30 +++- server/db/index.ts | 4 +- server/db/schema.ts | 4 +- server/services/ffmpeg.ts | 156 +----------------- server/types.ts | 2 + src/features/nodes/NodesPage.tsx | 18 +- src/features/review/AudioDetailPage.tsx | 49 ++---- src/features/setup/SetupPage.tsx | 29 ---- src/features/subtitles/SubtitleDetailPage.tsx | 19 +-- src/features/subtitles/SubtitleListPage.tsx | 28 +++- src/shared/lib/types.ts | 2 + unraid-template.xml | 40 +++++ 19 files changed, 171 insertions(+), 286 deletions(-) create mode 100644 unraid-template.xml diff --git a/.dockerignore b/.dockerignore index 4fd46bb..3dd473d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,3 +6,4 @@ data/ .claude/ .env* *.md +*.xml diff --git a/Dockerfile b/Dockerfile index bd003fc..47fab82 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml index 26b920d..60ef479 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/server/api/execute.ts b/server/api/execute.ts index db2f4a6..4e6845c 100644 --- a/server/api/execute.ts +++ b/server/api/execute.ts @@ -177,7 +177,11 @@ async function runJob(job: Job): Promise { 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, prefix = '') => { diff --git a/server/api/nodes.ts b/server/api/nodes.ts index 442a92b..c3ce0a0 100644 --- a/server/api/nodes.ts +++ b/server/api/nodes.ts @@ -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; diff --git a/server/api/review.ts b/server/api/review.ts index b8039dc..c015895 100644 --- a/server/api/review.ts +++ b/server/api/review.ts @@ -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, 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, 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) => { diff --git a/server/api/setup.ts b/server/api/setup.ts index dc7e93f..0da9380 100644 --- a/server/api/setup.ts +++ b/server/api/setup.ts @@ -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(); diff --git a/server/api/subtitles.ts b/server/api/subtitles.ts index 3bf3fa0..e446749 100644 --- a/server/api/subtitles.ts +++ b/server/api/subtitles.ts @@ -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, 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, 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) => { diff --git a/server/db/index.ts b/server/db/index.ts index 2fb1b08..b638319 100644 --- a/server/db/index.ts +++ b/server/db/index.ts @@ -22,8 +22,6 @@ const ENV_MAP: Record = { 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; } diff --git a/server/db/schema.ts b/server/db/schema.ts index 8cdb0e5..83058dd 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -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 = { sonarr_enabled: '0', subtitle_languages: JSON.stringify(['eng', 'deu', 'spa']), scan_running: '0', - movies_path: '', - series_path: '', }; diff --git a/server/services/ffmpeg.ts b/server/services/ffmpeg.ts index 92ceea2..cc11360 100644 --- a/server/services/ffmpeg.ts +++ b/server/services/ffmpeg.ts @@ -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 = { 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, "'\\''")}'`; diff --git a/server/types.ts b/server/types.ts index e04d386..3680057 100644 --- a/server/types.ts +++ b/server/types.ts @@ -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; diff --git a/src/features/nodes/NodesPage.tsx b/src/features/nodes/NodesPage.tsx index 64f2481..a5c2d8d 100644 --- a/src/features/nodes/NodesPage.tsx +++ b/src/features/nodes/NodesPage.tsx @@ -52,8 +52,8 @@ export function NodesPage() {

- 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 /movies/ and /series/ to the node's mount points.

{/* Add form */} @@ -86,6 +86,16 @@ export function NodesPage() { Work directory + +