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
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:
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user