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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user