add version badge in nav, apply path mappings at execution time, clear done/error jobs
All checks were successful
Build and Push Docker Image / build (push) Successful in 58s

- show version (from package.json) in nav bar, warn on frontend/server mismatch
- apply path_mappings to file access check and command string at execution time
  so existing scans with old jellyfin paths work without re-scanning
- add clear done/errors button on execute page
- bump version to 2026.03.04

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 17:22:14 +01:00
parent 37fae33bbc
commit 818b0d1396
7 changed files with 58 additions and 8 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "netfelix-audio-fix",
"version": "2026.02.26",
"version": "2026.03.04",
"scripts": {
"dev:server": "NODE_ENV=development bun --hot server/index.tsx",
"dev:client": "vite",

View File

@@ -1,6 +1,6 @@
import { Hono } from 'hono';
import { stream } from 'hono/streaming';
import { getDb } from '../db/index';
import { getDb, applyPathMappings, applyPathMappingsToCommand } from '../db/index';
import { execStream } from '../services/ssh';
import type { Job, Node, MediaItem, MediaStream } from '../types';
import { predictExtractedFiles } from '../services/ffmpeg';
@@ -129,6 +129,12 @@ app.post('/clear', (c) => {
return c.json({ ok: true, cleared: result.changes });
});
app.post('/clear-completed', (c) => {
const db = getDb();
const result = db.prepare("DELETE FROM jobs WHERE status IN ('done', 'error')").run();
return c.json({ ok: true, cleared: result.changes });
});
// ─── SSE ──────────────────────────────────────────────────────────────────────
app.get('/events', (c) => {
@@ -167,8 +173,9 @@ async function runJob(job: Job): Promise<void> {
if (!job.node_id) {
const itemRow = db.prepare('SELECT file_path FROM media_items WHERE id = ?').get(job.item_id) as { file_path: string } | undefined;
if (itemRow?.file_path) {
try { accessSync(itemRow.file_path, constants.R_OK | constants.W_OK); } catch (fsErr) {
const msg = `File not accessible: ${itemRow.file_path}\n${(fsErr as Error).message}`;
const mappedPath = applyPathMappings(itemRow.file_path);
try { accessSync(mappedPath, constants.R_OK | constants.W_OK); } catch (fsErr) {
const msg = `File not accessible: ${mappedPath}\n${(fsErr as Error).message}`;
db.prepare("UPDATE jobs SET status = 'error', output = ?, exit_code = 1, completed_at = datetime('now') WHERE id = ?").run(msg, job.id);
emitJobUpdate(job.id, 'error', msg);
db.prepare("UPDATE review_plans SET status = 'error' WHERE item_id = ?").run(job.item_id);
@@ -197,7 +204,8 @@ async function runJob(job: Job): Promise<void> {
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 mappedCmd = applyPathMappingsToCommand(job.command);
const proc = Bun.spawn(['sh', '-c', mappedCmd], { stdout: 'pipe', stderr: 'pipe' });
const readStream = async (readable: ReadableStream<Uint8Array>, prefix = '') => {
const reader = readable.getReader();
const decoder = new TextDecoder();

View File

@@ -111,12 +111,27 @@ export function getAllConfig(): Record<string, string> {
return result;
}
function getPathMappings(): [string, string][] {
const raw = getConfig('path_mappings');
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter(([f, t]: [string, string]) => f && t) : [];
} catch { return []; }
}
/** Apply path_mappings config to translate a Jellyfin path to a local container path. */
export function applyPathMappings(path: string): string {
const raw = getConfig('path_mappings') ?? '[]';
const mappings = JSON.parse(raw) as [string, string][];
for (const [from, to] of mappings) {
for (const [from, to] of getPathMappings()) {
if (path.startsWith(from)) return to + path.slice(from.length);
}
return path;
}
/** Apply all path_mappings as replaceAll on a command string (for baked-in paths). */
export function applyPathMappingsToCommand(cmd: string): string {
for (const [from, to] of getPathMappings()) {
cmd = cmd.replaceAll(from, to);
}
return cmd;
}

View File

@@ -19,6 +19,9 @@ app.use('/api/*', cors({ origin: ['http://localhost:5173', 'http://localhost:300
// ─── API routes ───────────────────────────────────────────────────────────────
import pkg from '../package.json';
app.get('/api/version', (c) => c.json({ version: pkg.version }));
app.route('/api/dashboard', dashboardRoutes);
app.route('/api/setup', setupRoutes);
app.route('/api/scan', scanRoutes);

View File

@@ -55,6 +55,7 @@ export function ExecutePage() {
const startAll = async () => { await api.post('/api/execute/start'); load(); };
const clearQueue = async () => { await api.post('/api/execute/clear'); load(); };
const clearCompleted = async () => { await api.post('/api/execute/clear-completed'); load(); };
const runJob = async (id: number) => { await api.post(`/api/execute/job/${id}/run`); load(); };
const cancelJob = async (id: number) => { await api.post(`/api/execute/job/${id}/cancel`); load(); };
const assignNode = async (jobId: number, nodeId: number | null) => {
@@ -88,6 +89,7 @@ export function ExecutePage() {
<div className="flex gap-3 mb-6">
{pending > 0 && <Button onClick={startAll}> Run all pending</Button>}
{pending > 0 && <Button variant="secondary" onClick={clearQueue}> Clear queue</Button>}
{(done > 0 || errors > 0) && <Button variant="secondary" onClick={clearCompleted}> Clear done/errors</Button>}
{jobs.length === 0 && (
<p className="text-gray-500 m-0">
No jobs yet. Go to <Link to="/review">Review</Link> and approve items first.

View File

@@ -1,5 +1,9 @@
import { createRootRoute, Link, Outlet } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
import { cn } from '~/shared/lib/utils';
import { api } from '~/shared/lib/api';
declare const __APP_VERSION__: string;
export const Route = createRootRoute({
component: RootLayout,
@@ -17,6 +21,19 @@ function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
);
}
function VersionBadge() {
const [serverVersion, setServerVersion] = useState<string | null>(null);
useEffect(() => { api.get<{ version: string }>('/api/version').then((d) => setServerVersion(d.version)).catch(() => {}); }, []);
const buildVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : null;
const mismatch = buildVersion && serverVersion && buildVersion !== serverVersion;
return (
<span className="text-[0.65rem] text-gray-400 font-mono ml-1" title={mismatch ? `Frontend: ${buildVersion}, Server: ${serverVersion}` : undefined}>
v{serverVersion ?? buildVersion ?? '?'}
{mismatch && <span className="text-amber-500 ml-1" title="Frontend and server versions differ — rebuild or refresh"></span>}
</span>
);
}
function RootLayout() {
const isDev = import.meta.env.DEV;
return (
@@ -30,6 +47,7 @@ function RootLayout() {
<Link to="/" className="font-bold text-[0.95rem] mr-5 no-underline text-gray-900">
🎬 netfelix-audio-fix
</Link>
<VersionBadge />
<NavLink to="/scan">Scan</NavLink>
<NavLink to="/review/audio">Audio</NavLink>
<NavLink to="/review/subtitles">Subtitles</NavLink>

View File

@@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react-swc';
import tailwindcss from '@tailwindcss/vite';
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
import { resolve } from 'node:path';
import pkg from './package.json' with { type: 'json' };
export default defineConfig({
plugins: [
@@ -21,6 +22,9 @@ export default defineConfig({
'/api': { target: 'http://localhost:3000', changeOrigin: true },
},
},
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
build: {
outDir: 'dist',
emptyOutDir: true,