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/
|
.claude/
|
||||||
.env*
|
.env*
|
||||||
*.md
|
*.md
|
||||||
|
*.xml
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = '') => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '',
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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, "'\\''")}'`;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
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