initial implementation: jellyfin audio/subtitle cleanup service

bun + hono + htmx service with sqlite, jellyfin/radarr/sonarr api
clients, stream analyzer, ffmpeg command builder, ssh remote execution,
setup wizard, scan with sse progress, review ui with inline edits,
execute queue, remote node management, docker deployment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 22:29:33 +01:00
commit ea536ce533
29 changed files with 3938 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
data/*.db
data/*.db-shm
data/*.db-wal
bun.lockb
.env

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM oven/bun:1 AS base
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
COPY . .
EXPOSE 3000
ENV DATA_DIR=/data
ENV PORT=3000
VOLUME ["/data"]
CMD ["bun", "run", "src/server.tsx"]

48
bun.lock Normal file
View File

@@ -0,0 +1,48 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "netfelix-audio-fix",
"dependencies": {
"hono": "^4",
"ssh2": "^1",
},
"devDependencies": {
"@types/ssh2": "^1",
"bun-types": "latest",
},
},
},
"packages": {
"@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
"buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
"hono": ["hono@4.12.3", "", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="],
"nan": ["nan@2.25.0", "", {}, "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"bun-types/@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="],
"bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

11
docker-compose.yml Normal file
View File

@@ -0,0 +1,11 @@
services:
netfelix-audio-fix:
build: .
ports:
- "3000:3000"
volumes:
- ./data:/data
environment:
- DATA_DIR=/data
- PORT=3000
restart: unless-stopped

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "netfelix-audio-fix",
"version": "2026.02.26",
"scripts": {
"dev": "bun --hot src/server.tsx",
"start": "bun src/server.tsx"
},
"dependencies": {
"hono": "^4",
"ssh2": "^1"
},
"devDependencies": {
"@types/ssh2": "^1",
"bun-types": "latest"
}
}

389
public/app.css Normal file
View File

@@ -0,0 +1,389 @@
/* ─── Base overrides ──────────────────────────────────────────────────────── */
:root {
--nav-height: 3.5rem;
--color-keep: #2d9a5f;
--color-remove: #c0392b;
--color-pending: #888;
--color-approved: #2d9a5f;
--color-skipped: #888;
--color-done: #2d9a5f;
--color-error: #c0392b;
--color-noop: #555;
--font-mono: 'JetBrains Mono', 'Fira Mono', 'Cascadia Code', monospace;
}
body {
margin: 0;
}
/* ─── Nav ─────────────────────────────────────────────────────────────────── */
.app-nav {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0 1.5rem;
height: var(--nav-height);
background: var(--pico-background-color);
border-bottom: 1px solid var(--pico-muted-border-color);
position: sticky;
top: 0;
z-index: 100;
}
.app-nav .brand {
font-weight: 700;
font-size: 1.05rem;
margin-right: 1.5rem;
text-decoration: none;
color: var(--pico-color);
}
.app-nav a {
padding: 0.35rem 0.75rem;
border-radius: 6px;
text-decoration: none;
font-size: 0.9rem;
color: var(--pico-muted-color);
transition: background 0.15s, color 0.15s;
}
.app-nav a:hover,
.app-nav a.active {
background: var(--pico-secondary-background);
color: var(--pico-color);
}
.app-nav .spacer { flex: 1; }
/* ─── Layout ──────────────────────────────────────────────────────────────── */
.page {
max-width: 1400px;
margin: 0 auto;
padding: 1.5rem 1.5rem 3rem;
}
.page-header {
display: flex;
align-items: baseline;
gap: 1rem;
margin-bottom: 1.5rem;
}
.page-header h1 {
margin: 0;
font-size: 1.5rem;
}
/* ─── Stat cards ──────────────────────────────────────────────────────────── */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
border: 1px solid var(--pico-muted-border-color);
border-radius: 8px;
padding: 1rem 1.25rem;
text-align: center;
}
.stat-card .num {
font-size: 2rem;
font-weight: 700;
line-height: 1;
}
.stat-card .label {
font-size: 0.78rem;
color: var(--pico-muted-color);
margin-top: 0.25rem;
}
/* ─── Badges / status ─────────────────────────────────────────────────────── */
.badge {
display: inline-block;
font-size: 0.72rem;
font-weight: 600;
padding: 0.15em 0.55em;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.04em;
background: var(--pico-secondary-background);
color: var(--pico-muted-color);
}
.badge-keep { background: #d4edda; color: #155724; }
.badge-remove { background: #f8d7da; color: #721c24; }
.badge-pending { background: #e2e3e5; color: #383d41; }
.badge-approved{ background: #d4edda; color: #155724; }
.badge-skipped { background: #e2e3e5; color: #383d41; }
.badge-done { background: #d1ecf1; color: #0c5460; }
.badge-error { background: #f8d7da; color: #721c24; }
.badge-noop { background: #e2e3e5; color: #383d41; }
.badge-running { background: #fff3cd; color: #856404; }
.badge-manual { background: #fde8c8; color: #7d4400; }
/* ─── Filter tabs ─────────────────────────────────────────────────────────── */
.filter-tabs {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.filter-tabs a,
.filter-tabs button {
padding: 0.35rem 0.9rem;
border-radius: 6px;
font-size: 0.85rem;
border: 1px solid var(--pico-muted-border-color);
background: transparent;
cursor: pointer;
text-decoration: none;
color: var(--pico-muted-color);
}
.filter-tabs a.active,
.filter-tabs button.active {
background: var(--pico-primary);
border-color: var(--pico-primary);
color: #fff;
}
/* ─── Tables ──────────────────────────────────────────────────────────────── */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.data-table th {
text-align: left;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--pico-muted-color);
padding: 0.5rem 0.75rem;
border-bottom: 2px solid var(--pico-muted-border-color);
white-space: nowrap;
}
.data-table td {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--pico-muted-border-color);
vertical-align: middle;
}
.data-table tr:hover td {
background: var(--pico-secondary-background);
}
.data-table tr.expanded td {
background: var(--pico-secondary-background);
}
.data-table td.mono {
font-family: var(--font-mono);
font-size: 0.8rem;
}
/* ─── Stream decision table ───────────────────────────────────────────────── */
.stream-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
margin-top: 0.5rem;
}
.stream-table th {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--pico-muted-color);
padding: 0.3rem 0.6rem;
border-bottom: 1px solid var(--pico-muted-border-color);
}
.stream-table td {
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--pico-muted-border-color);
vertical-align: middle;
}
.stream-row-keep { background: #f0fff4; }
.stream-row-remove { background: #fff5f5; }
/* ─── Action toggle buttons ───────────────────────────────────────────────── */
.toggle-keep,
.toggle-remove {
border: none;
border-radius: 4px;
padding: 0.2em 0.6em;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
min-width: 5rem;
}
.toggle-keep { background: var(--color-keep); color: #fff; }
.toggle-remove { background: var(--color-remove); color: #fff; }
/* ─── Progress bar ────────────────────────────────────────────────────────── */
.progress-wrap {
background: var(--pico-muted-border-color);
border-radius: 999px;
height: 0.5rem;
overflow: hidden;
margin: 0.75rem 0;
}
.progress-bar {
height: 100%;
background: var(--pico-primary);
border-radius: 999px;
transition: width 0.3s ease;
}
/* ─── Log output ──────────────────────────────────────────────────────────── */
.log-output {
font-family: var(--font-mono);
font-size: 0.78rem;
background: #1a1a1a;
color: #d4d4d4;
padding: 0.75rem 1rem;
border-radius: 6px;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
/* ─── Command preview ─────────────────────────────────────────────────────── */
.command-preview {
font-family: var(--font-mono);
font-size: 0.78rem;
background: #1a1a1a;
color: #9cdcfe;
padding: 0.75rem 1rem;
border-radius: 6px;
white-space: pre-wrap;
word-break: break-all;
border: none;
width: 100%;
resize: vertical;
min-height: 3rem;
}
/* ─── Detail panel ────────────────────────────────────────────────────────── */
.detail-panel {
border: 1px solid var(--pico-muted-border-color);
border-radius: 8px;
padding: 1.25rem;
margin-top: 0.25rem;
margin-bottom: 1rem;
}
.detail-meta {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
margin-bottom: 1rem;
font-size: 0.85rem;
}
.detail-meta dt {
color: var(--pico-muted-color);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.1rem;
}
.detail-meta dd {
margin: 0;
font-weight: 500;
}
/* ─── Setup wizard ────────────────────────────────────────────────────────── */
.wizard-steps {
display: flex;
gap: 0;
margin-bottom: 2rem;
border-bottom: 2px solid var(--pico-muted-border-color);
}
.wizard-step {
padding: 0.6rem 1.25rem;
font-size: 0.85rem;
color: var(--pico-muted-color);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
}
.wizard-step.active {
color: var(--pico-primary);
border-bottom-color: var(--pico-primary);
font-weight: 600;
}
.wizard-step.done {
color: var(--color-keep);
}
/* ─── Connection status ───────────────────────────────────────────────────── */
.conn-status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.82rem;
padding: 0.3em 0.7em;
border-radius: 5px;
}
.conn-status.ok { background: #d4edda; color: #155724; }
.conn-status.error { background: #f8d7da; color: #721c24; }
.conn-status.checking { background: #fff3cd; color: #856404; }
/* ─── Inline lang select ──────────────────────────────────────────────────── */
.lang-select {
font-size: 0.82rem;
padding: 0.2em 0.5em;
border-radius: 4px;
border: 1px solid var(--pico-muted-border-color);
background: var(--pico-background-color);
cursor: pointer;
}
/* ─── Alerts ──────────────────────────────────────────────────────────────── */
.alert {
padding: 0.75rem 1rem;
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.alert-warning { background: #fff3cd; color: #856404; border: 1px solid #ffc107; }
.alert-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.alert-info { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; }
/* ─── HTMX loading indicator ─────────────────────────────────────────────── */
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline; }
.htmx-request.htmx-indicator { display: inline; }
/* ─── Utility ─────────────────────────────────────────────────────────────── */
.muted { color: var(--pico-muted-color); }
.mono { font-family: var(--font-mono); font-size: 0.8rem; }
.truncate { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.flex-row { display: flex; align-items: center; gap: 0.5rem; }
.actions-col { white-space: nowrap; display: flex; gap: 0.4rem; align-items: center; }
button[data-size="sm"],
a[data-size="sm"] {
padding: 0.25rem 0.65rem;
font-size: 0.8rem;
}

255
src/api/execute.tsx Normal file
View File

@@ -0,0 +1,255 @@
import { Hono } from 'hono';
import { stream } from 'hono/streaming';
import { getDb } from '../db/index';
import { execStream, execOnce } from '../services/ssh';
import type { Job, Node, MediaItem } from '../types';
import { ExecutePage } from '../views/execute';
const app = new Hono();
// ─── SSE state ────────────────────────────────────────────────────────────────
const jobListeners = new Set<(data: string) => void>();
function emitJobUpdate(jobId: number, status: string, output?: string): void {
const line = `event: job_update\ndata: ${JSON.stringify({ id: jobId, status, output })}\n\n`;
for (const l of jobListeners) l(line);
}
// ─── List page ────────────────────────────────────────────────────────────────
app.get('/', (c) => {
const db = getDb();
const jobRows = db.prepare(`
SELECT j.*, mi.name, mi.type, mi.series_name, mi.season_number, mi.episode_number,
mi.file_path,
n.name as node_name, n.host, n.port, n.username,
n.private_key, n.ffmpeg_path, n.work_dir, n.status as node_status
FROM jobs j
LEFT JOIN media_items mi ON mi.id = j.item_id
LEFT JOIN nodes n ON n.id = j.node_id
ORDER BY j.created_at DESC
LIMIT 200
`).all() as (Job & {
name: string;
type: string;
series_name: string | null;
season_number: number | null;
episode_number: number | null;
file_path: string;
node_name: string | null;
host: string | null;
port: number | null;
username: string | null;
private_key: string | null;
ffmpeg_path: string | null;
work_dir: string | null;
node_status: string | null;
})[];
const jobs = jobRows.map((r) => ({
job: r as unknown as Job,
item: r.name ? {
id: r.item_id,
name: r.name,
type: r.type,
series_name: r.series_name,
season_number: r.season_number,
episode_number: r.episode_number,
file_path: r.file_path,
} as unknown as MediaItem : null,
node: r.node_name ? {
id: r.node_id!,
name: r.node_name,
host: r.host!,
port: r.port!,
username: r.username!,
private_key: r.private_key!,
ffmpeg_path: r.ffmpeg_path!,
work_dir: r.work_dir!,
status: r.node_status!,
} as unknown as Node : null,
}));
const nodes = db.prepare('SELECT * FROM nodes ORDER BY name').all() as Node[];
return c.html(<ExecutePage jobs={jobs} nodes={nodes} />);
});
// ─── Start all pending ────────────────────────────────────────────────────────
app.post('/start', (c) => {
const db = getDb();
const pending = db.prepare(
"SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at"
).all() as Job[];
for (const job of pending) {
runJob(job).catch((err) => console.error(`Job ${job.id} failed:`, err));
}
return c.redirect('/execute');
});
// ─── Assign node ──────────────────────────────────────────────────────────────
app.post('/job/:id/assign', async (c) => {
const db = getDb();
const jobId = Number(c.req.param('id'));
const body = await c.req.formData();
const nodeId = body.get('node_id') ? Number(body.get('node_id')) : null;
db.prepare('UPDATE jobs SET node_id = ? WHERE id = ?').run(nodeId, jobId);
return c.redirect('/execute');
});
// ─── Run single job ───────────────────────────────────────────────────────────
app.post('/job/:id/run', async (c) => {
const db = getDb();
const jobId = Number(c.req.param('id'));
const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(jobId) as Job | undefined;
if (!job || job.status !== 'pending') {
return c.redirect('/execute');
}
runJob(job).catch((err) => console.error(`Job ${job.id} failed:`, err));
return c.redirect('/execute');
});
// ─── Cancel job ───────────────────────────────────────────────────────────────
app.post('/job/:id/cancel', (c) => {
const db = getDb();
const jobId = Number(c.req.param('id'));
db.prepare("DELETE FROM jobs WHERE id = ? AND status = 'pending'").run(jobId);
return c.redirect('/execute');
});
// ─── SSE ──────────────────────────────────────────────────────────────────────
app.get('/events', (c) => {
return stream(c, async (s) => {
c.header('Content-Type', 'text/event-stream');
c.header('Cache-Control', 'no-cache');
const queue: string[] = [];
let resolve: (() => void) | null = null;
const listener = (data: string) => {
queue.push(data);
resolve?.();
};
jobListeners.add(listener);
s.onAbort(() => { jobListeners.delete(listener); });
try {
while (!s.closed) {
if (queue.length > 0) {
await s.write(queue.shift()!);
} else {
await new Promise<void>((res) => {
resolve = res;
setTimeout(res, 15_000);
});
resolve = null;
if (queue.length === 0) await s.write(': keepalive\n\n');
}
}
} finally {
jobListeners.delete(listener);
}
});
});
// ─── Job execution ────────────────────────────────────────────────────────────
async function runJob(job: Job): Promise<void> {
const db = getDb();
db.prepare(
"UPDATE jobs SET status = 'running', started_at = datetime('now'), output = '' WHERE id = ?"
).run(job.id);
emitJobUpdate(job.id, 'running');
let outputLines: string[] = [];
try {
if (job.node_id) {
// Remote execution
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 to DB every 20 lines
if (outputLines.length % 20 === 0) {
db.prepare('UPDATE jobs SET output = ? WHERE id = ?').run(outputLines.join('\n'), job.id);
emitJobUpdate(job.id, 'running', outputLines.join('\n'));
}
}
} else {
// Local execution — spawn ffmpeg directly
const proc = Bun.spawn(['sh', '-c', job.command], {
stdout: 'pipe',
stderr: 'pipe',
});
const readStream = async (readable: ReadableStream<Uint8Array>, prefix = '') => {
const reader = readable.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
const lines = text.split('\n').filter((l) => l.trim());
for (const line of lines) {
outputLines.push(prefix + line);
}
if (outputLines.length % 20 === 0) {
db.prepare('UPDATE jobs SET output = ? WHERE id = ?').run(outputLines.join('\n'), job.id);
emitJobUpdate(job.id, 'running', outputLines.join('\n'));
}
}
} catch { /* ignore */ }
};
await Promise.all([
readStream(proc.stdout),
readStream(proc.stderr, '[stderr] '),
proc.exited,
]);
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`FFmpeg exited with code ${exitCode}`);
}
}
const fullOutput = outputLines.join('\n');
db.prepare(
"UPDATE jobs SET status = 'done', exit_code = 0, output = ?, completed_at = datetime('now') WHERE id = ?"
).run(fullOutput, job.id);
emitJobUpdate(job.id, 'done', fullOutput);
// Mark plan as done
db.prepare(
"UPDATE review_plans SET status = 'done' WHERE item_id = ?"
).run(job.item_id);
} catch (err) {
const fullOutput = outputLines.join('\n') + '\n' + String(err);
db.prepare(
"UPDATE jobs SET status = 'error', exit_code = 1, output = ?, completed_at = datetime('now') WHERE id = ?"
).run(fullOutput, job.id);
emitJobUpdate(job.id, 'error', fullOutput);
db.prepare(
"UPDATE review_plans SET status = 'error' WHERE item_id = ?"
).run(job.item_id);
}
}
export default app;

73
src/api/nodes.tsx Normal file
View File

@@ -0,0 +1,73 @@
import { Hono } from 'hono';
import { getDb } from '../db/index';
import { testConnection } from '../services/ssh';
import type { Node } from '../types';
import { NodesPage, NodesList, NodeStatusBadge } from '../views/nodes';
const app = new Hono();
app.get('/', (c) => {
const db = getDb();
const nodes = db.prepare('SELECT * FROM nodes ORDER BY name').all() as Node[];
return c.html(<NodesPage nodes={nodes} />);
});
app.post('/', async (c) => {
const db = getDb();
const body = await c.req.formData();
const name = body.get('name') as string;
const host = body.get('host') as string;
const port = Number(body.get('port') ?? '22');
const username = body.get('username') as string;
const ffmpegPath = (body.get('ffmpeg_path') as string) || 'ffmpeg';
const workDir = (body.get('work_dir') as string) || '/tmp';
const keyFile = body.get('private_key') as File | null;
if (!name || !host || !username || !keyFile) {
return c.html(<div class="alert alert-error">All fields are required.</div>);
}
const privateKey = await keyFile.text();
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);
} catch (e) {
if (String(e).includes('UNIQUE')) {
return c.html(<div class="alert alert-error">A node named "{name}" already exists.</div>);
}
throw e;
}
const nodes = db.prepare('SELECT * FROM nodes ORDER BY name').all() as Node[];
return c.html(<NodesList nodes={nodes} />);
});
app.post('/:id/delete', (c) => {
const db = getDb();
const id = Number(c.req.param('id'));
db.prepare('DELETE FROM nodes WHERE id = ?').run(id);
return c.redirect('/nodes');
});
app.post('/:id/test', async (c) => {
const db = getDb();
const id = Number(c.req.param('id'));
const node = db.prepare('SELECT * FROM nodes WHERE id = ?').get(id) as Node | undefined;
if (!node) return c.notFound();
const result = await testConnection(node);
const status = result.ok ? 'ok' : 'error';
db.prepare(
"UPDATE nodes SET status = ?, last_checked_at = datetime('now') WHERE id = ?"
).run(status, id);
return c.html(<NodeStatusBadge status={result.ok ? 'ok' : `error: ${result.error}`} />);
});
export default app;

305
src/api/review.tsx Normal file
View File

@@ -0,0 +1,305 @@
import { Hono } from 'hono';
import { getDb, getConfig } from '../db/index';
import { analyzeItem } from '../services/analyzer';
import { buildCommand } from '../services/ffmpeg';
import { normalizeLanguage } from '../services/jellyfin';
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '../types';
import {
ReviewListPage,
ReviewDetailPage,
ReviewDetailFragment,
} from '../views/review';
const app = new Hono();
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getSubtitleLanguages(): string[] {
return JSON.parse(getConfig('subtitle_languages') ?? '["eng","deu","spa"]');
}
function computeCommand(
item: MediaItem,
streams: MediaStream[],
decisions: StreamDecision[]
): string | null {
if (decisions.every((d) => d.action === 'keep')) return null;
return buildCommand(item, streams, decisions);
}
function countsByFilter(db: ReturnType<typeof getDb>): Record<string, number> {
const total = (db.prepare('SELECT COUNT(*) as n FROM review_plans').get() as { n: number }).n;
const noops = (db.prepare('SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1').get() as { n: number }).n;
const pending = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }).n;
const approved = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").get() as { n: number }).n;
const skipped = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'skipped'").get() as { n: number }).n;
const done = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'done'").get() as { n: number }).n;
const error = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'error'").get() as { n: number }).n;
const manual = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE needs_review = 1 AND original_language IS NULL").get() as { n: number }).n;
return { all: total, needs_action: pending, noop: noops, approved, skipped, done, error, manual };
}
// ─── List view ────────────────────────────────────────────────────────────────
app.get('/', (c) => {
const db = getDb();
const filter = c.req.query('filter') ?? 'all';
let whereClause = '1=1';
switch (filter) {
case 'needs_action': whereClause = "rp.status = 'pending' AND rp.is_noop = 0"; break;
case 'noop': whereClause = 'rp.is_noop = 1'; break;
case 'manual': whereClause = 'mi.needs_review = 1 AND mi.original_language IS NULL'; break;
case 'approved': whereClause = "rp.status = 'approved'"; break;
case 'skipped': whereClause = "rp.status = 'skipped'"; break;
case 'done': whereClause = "rp.status = 'done'"; break;
case 'error': whereClause = "rp.status = 'error'"; break;
}
const rows = db.prepare(`
SELECT
mi.*,
rp.id as plan_id,
rp.status as plan_status,
rp.is_noop,
rp.notes as plan_notes,
rp.reviewed_at,
rp.created_at as plan_created_at,
COUNT(CASE WHEN sd.action = 'remove' THEN 1 END) as remove_count,
COUNT(CASE WHEN sd.action = 'keep' THEN 1 END) as keep_count
FROM media_items mi
LEFT JOIN review_plans rp ON rp.item_id = mi.id
LEFT JOIN stream_decisions sd ON sd.plan_id = rp.id
WHERE ${whereClause}
GROUP BY mi.id
ORDER BY mi.series_name NULLS LAST, mi.name, mi.season_number, mi.episode_number
LIMIT 500
`).all() as (MediaItem & {
plan_id: number | null;
plan_status: string | null;
is_noop: number | null;
plan_notes: string | null;
reviewed_at: string | null;
plan_created_at: string | null;
remove_count: number;
keep_count: number;
})[];
const items = rows.map((r) => ({
item: r as unknown as MediaItem,
plan: r.plan_id != null ? {
id: r.plan_id,
item_id: r.id,
status: r.plan_status ?? 'pending',
is_noop: r.is_noop ?? 0,
notes: r.plan_notes,
reviewed_at: r.reviewed_at,
created_at: r.plan_created_at ?? '',
} as ReviewPlan : null,
removeCount: r.remove_count,
keepCount: r.keep_count,
}));
const totalCounts = countsByFilter(db);
return c.html(<ReviewListPage items={items} filter={filter} totalCounts={totalCounts} />);
});
// ─── Detail view ──────────────────────────────────────────────────────────────
app.get('/:id', (c) => {
const db = getDb();
const id = Number(c.req.param('id'));
const { item, streams, plan, decisions, command } = loadItemDetail(db, id);
if (!item) return c.notFound();
// Inline HTMX expansion vs full page
const isHtmx = c.req.header('HX-Request') === 'true';
if (isHtmx) {
return c.html(
<ReviewDetailFragment item={item} streams={streams} plan={plan} decisions={decisions} command={command} />
);
}
return c.html(
<ReviewDetailPage item={item} streams={streams} plan={plan} decisions={decisions} command={command} />
);
});
// ─── Override original language ───────────────────────────────────────────────
app.patch('/:id/language', async (c) => {
const db = getDb();
const id = Number(c.req.param('id'));
const body = await c.req.formData();
const lang = (body.get('language') as string) || null;
db.prepare(
"UPDATE media_items SET original_language = ?, orig_lang_source = 'manual', needs_review = 0 WHERE id = ?"
).run(lang ? normalizeLanguage(lang) : null, id);
// Re-analyze with new language
reanalyze(db, id);
const { item, streams, plan, decisions, command } = loadItemDetail(db, id);
if (!item) return c.notFound();
return c.html(
<ReviewDetailFragment item={item} streams={streams} plan={plan} decisions={decisions} command={command} />
);
});
// ─── Toggle stream action ─────────────────────────────────────────────────────
app.patch('/:id/stream/:streamId', async (c) => {
const db = getDb();
const itemId = Number(c.req.param('id'));
const streamId = Number(c.req.param('streamId'));
const body = await c.req.formData();
const action = body.get('action') as 'keep' | 'remove';
// Get plan
const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number } | undefined;
if (!plan) return c.notFound();
db.prepare(
'UPDATE stream_decisions SET action = ? WHERE plan_id = ? AND stream_id = ?'
).run(action, plan.id, streamId);
// Recompute is_noop
const allKeep = (db.prepare(
"SELECT COUNT(*) as n FROM stream_decisions WHERE plan_id = ? AND action = 'remove'"
).get(plan.id) as { n: number }).n === 0;
db.prepare('UPDATE review_plans SET is_noop = ? WHERE id = ?').run(allKeep ? 1 : 0, plan.id);
const { item, streams, decisions, command } = loadItemDetail(db, itemId);
if (!item) return c.notFound();
const planFull = db.prepare('SELECT * FROM review_plans WHERE id = ?').get(plan.id) as ReviewPlan;
return c.html(
<ReviewDetailFragment item={item} streams={streams} plan={planFull} decisions={decisions} command={command} />
);
});
// ─── Approve ──────────────────────────────────────────────────────────────────
app.post('/:id/approve', (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();
db.prepare(
"UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?"
).run(plan.id);
// Create job
if (!plan.is_noop) {
const { item, streams, decisions } = loadItemDetail(db, id);
if (item) {
const command = buildCommand(item, streams, decisions);
db.prepare(
"INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')"
).run(id, command);
}
}
const isHtmx = c.req.header('HX-Request') === 'true';
return isHtmx ? c.redirect('/review', 303) : c.redirect('/review');
});
// ─── Skip ─────────────────────────────────────────────────────────────────────
app.post('/:id/skip', (c) => {
const db = getDb();
const id = Number(c.req.param('id'));
db.prepare(
"UPDATE review_plans SET status = 'skipped', reviewed_at = datetime('now') WHERE item_id = ?"
).run(id);
return c.redirect('/review');
});
// ─── Approve all ──────────────────────────────────────────────────────────────
app.post('/approve-all', (c) => {
const db = getDb();
const pending = db.prepare(
"SELECT rp.*, mi.id as item_id FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE rp.status = 'pending' AND rp.is_noop = 0"
).all() as (ReviewPlan & { item_id: number })[];
for (const plan of pending) {
db.prepare(
"UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?"
).run(plan.id);
const { item, streams, decisions } = loadItemDetail(db, plan.item_id);
if (item) {
const command = buildCommand(item, streams, decisions);
db.prepare(
"INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')"
).run(plan.item_id, command);
}
}
return c.redirect('/review');
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
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 };
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 && decisions.some((d) => d.action === 'remove')
? buildCommand(item, streams, decisions)
: null;
return { item, streams, plan: plan ?? null, decisions, command };
}
function reanalyze(db: ReturnType<typeof getDb>, itemId: number): void {
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(itemId) as MediaItem;
if (!item) return;
const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(itemId) as MediaStream[];
const subtitleLanguages = getSubtitleLanguages();
const analysis = analyzeItem(
{ original_language: item.original_language, needs_review: item.needs_review },
streams,
{ subtitleLanguages }
);
// Upsert plan
db.prepare(`
INSERT INTO review_plans (item_id, status, is_noop, notes)
VALUES (?, 'pending', ?, ?)
ON CONFLICT(item_id) DO UPDATE SET
status = 'pending',
is_noop = excluded.is_noop,
notes = excluded.notes
`).run(itemId, analysis.is_noop ? 1 : 0, analysis.notes);
const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number };
// Replace decisions
db.prepare('DELETE FROM stream_decisions WHERE plan_id = ?').run(plan.id);
for (const dec of analysis.decisions) {
db.prepare(
'INSERT INTO stream_decisions (plan_id, stream_id, action, target_index) VALUES (?, ?, ?, ?)'
).run(plan.id, dec.stream_id, dec.action, dec.target_index);
}
}
export default app;

341
src/api/scan.tsx Normal file
View File

@@ -0,0 +1,341 @@
import { Hono } from 'hono';
import { stream } from 'hono/streaming';
import { getDb, getConfig, setConfig, getAllConfig } from '../db/index';
import { getAllItems, extractOriginalLanguage, mapStream, normalizeLanguage } from '../services/jellyfin';
import { getOriginalLanguage as radarrLang } from '../services/radarr';
import { getOriginalLanguage as sonarrLang } from '../services/sonarr';
import { analyzeItem } from '../services/analyzer';
import { buildCommand } from '../services/ffmpeg';
import type { MediaItem, MediaStream } from '../types';
import { ScanPage } from '../views/scan';
import { DashboardPage } from '../views/dashboard';
const app = new Hono();
// ─── State: single in-process scan ───────────────────────────────────────────
let scanAbort: AbortController | null = null;
const scanListeners = new Set<(data: string) => void>();
function emitSse(type: string, data: unknown): void {
const line = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
for (const listener of scanListeners) listener(line);
}
// ─── Pages ────────────────────────────────────────────────────────────────────
app.get('/', (c) => {
const db = getDb();
const running = getConfig('scan_running') === '1';
const total = (db.prepare('SELECT COUNT(*) as n FROM media_items').get() as { n: number }).n;
const scanned = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'scanned'").get() as { n: number }).n;
const errors = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'error'").get() as { n: number }).n;
const recentItems = db.prepare(
"SELECT name, type, scan_status FROM media_items ORDER BY last_scanned_at DESC LIMIT 50"
).all() as { name: string; type: string; scan_status: string }[];
return c.html(
<ScanPage
running={running}
progress={{ scanned, total, errors }}
recentItems={recentItems}
/>
);
});
// ─── Start scan ───────────────────────────────────────────────────────────────
app.post('/start', async (c) => {
if (getConfig('scan_running') === '1') {
return c.redirect('/scan');
}
setConfig('scan_running', '1');
// Start scan in background (fire and forget)
runScan().catch((err) => {
console.error('Scan error:', err);
setConfig('scan_running', '0');
emitSse('error', { message: String(err) });
});
return c.redirect('/scan');
});
// ─── Stop scan ────────────────────────────────────────────────────────────────
app.post('/stop', (c) => {
scanAbort?.abort();
setConfig('scan_running', '0');
return c.redirect('/scan');
});
// ─── SSE stream ───────────────────────────────────────────────────────────────
app.get('/events', (c) => {
return stream(c, async (s) => {
c.header('Content-Type', 'text/event-stream');
c.header('Cache-Control', 'no-cache');
c.header('Connection', 'keep-alive');
const queue: string[] = [];
let resolve: (() => void) | null = null;
const listener = (data: string) => {
queue.push(data);
resolve?.();
};
scanListeners.add(listener);
s.onAbort(() => { scanListeners.delete(listener); });
try {
while (!s.closed) {
if (queue.length > 0) {
await s.write(queue.shift()!);
} else {
await new Promise<void>((res) => {
resolve = res;
setTimeout(res, 15_000); // keepalive every 15s
});
resolve = null;
if (queue.length === 0) {
await s.write(': keepalive\n\n');
}
}
}
} finally {
scanListeners.delete(listener);
}
});
});
// ─── Core scan logic ──────────────────────────────────────────────────────────
async function runScan(): Promise<void> {
scanAbort = new AbortController();
const { signal } = scanAbort;
const db = getDb();
const cfg = getAllConfig();
const jellyfinCfg = {
url: cfg.jellyfin_url,
apiKey: cfg.jellyfin_api_key,
userId: cfg.jellyfin_user_id,
};
const subtitleLanguages: string[] = JSON.parse(cfg.subtitle_languages ?? '["eng","deu","spa"]');
const radarrEnabled = cfg.radarr_enabled === '1';
const sonarrEnabled = cfg.sonarr_enabled === '1';
let scanned = 0;
let errors = 0;
let total = 0;
// Count total items first (rough)
try {
const countUrl = new URL(`${jellyfinCfg.url}/Users/${jellyfinCfg.userId}/Items`);
countUrl.searchParams.set('Recursive', 'true');
countUrl.searchParams.set('IncludeItemTypes', 'Movie,Episode');
countUrl.searchParams.set('Limit', '1');
const countRes = await fetch(countUrl.toString(), {
headers: { 'X-Emby-Token': jellyfinCfg.apiKey },
});
if (countRes.ok) {
const body = await countRes.json() as { TotalRecordCount: number };
total = body.TotalRecordCount;
}
} catch { /* ignore */ }
const upsertItem = db.prepare(`
INSERT INTO media_items (
jellyfin_id, type, name, series_name, series_jellyfin_id,
season_number, episode_number, year, file_path, file_size, container,
original_language, orig_lang_source, needs_review,
imdb_id, tmdb_id, tvdb_id,
scan_status, last_scanned_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
'scanned', datetime('now')
)
ON CONFLICT(jellyfin_id) DO UPDATE SET
type = excluded.type,
name = excluded.name,
series_name = excluded.series_name,
series_jellyfin_id = excluded.series_jellyfin_id,
season_number = excluded.season_number,
episode_number = excluded.episode_number,
year = excluded.year,
file_path = excluded.file_path,
file_size = excluded.file_size,
container = excluded.container,
original_language = excluded.original_language,
orig_lang_source = excluded.orig_lang_source,
needs_review = excluded.needs_review,
imdb_id = excluded.imdb_id,
tmdb_id = excluded.tmdb_id,
tvdb_id = excluded.tvdb_id,
scan_status = 'scanned',
last_scanned_at = datetime('now')
`);
const deleteStreams = db.prepare('DELETE FROM media_streams WHERE item_id = ?');
const insertStream = db.prepare(`
INSERT INTO media_streams (
item_id, stream_index, type, codec, language, language_display,
title, is_default, is_forced, is_hearing_impaired,
channels, channel_layout, bit_rate, sample_rate
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const upsertPlan = db.prepare(`
INSERT INTO review_plans (item_id, status, is_noop, notes)
VALUES (?, 'pending', ?, ?)
ON CONFLICT(item_id) DO UPDATE SET
is_noop = excluded.is_noop,
notes = excluded.notes
`);
const upsertDecision = db.prepare(`
INSERT INTO stream_decisions (plan_id, stream_id, action, target_index)
VALUES (?, ?, ?, ?)
ON CONFLICT(plan_id, stream_id) DO UPDATE SET
action = excluded.action,
target_index = excluded.target_index
`);
const getItemByJellyfinId = db.prepare('SELECT id FROM media_items WHERE jellyfin_id = ?');
const getPlanByItemId = db.prepare('SELECT id FROM review_plans WHERE item_id = ?');
const getStreamsByItemId = db.prepare('SELECT * FROM media_streams WHERE item_id = ?');
for await (const jellyfinItem of getAllItems(jellyfinCfg)) {
if (signal.aborted) break;
scanned++;
emitSse('progress', {
scanned,
total,
current_item: jellyfinItem.Name,
errors,
running: true,
});
try {
const providerIds = jellyfinItem.ProviderIds ?? {};
const imdbId = providerIds['Imdb'] ?? null;
const tmdbId = providerIds['Tmdb'] ?? null;
const tvdbId = providerIds['Tvdb'] ?? null;
// Determine original language
let origLang: string | null = extractOriginalLanguage(jellyfinItem);
let origLangSource: string = 'jellyfin';
let needsReview = origLang ? 0 : 1;
// Cross-check with Radarr (movies)
if (jellyfinItem.Type === 'Movie' && radarrEnabled && (tmdbId || imdbId)) {
const radarrLanguage = await radarrLang(
{ url: cfg.radarr_url, apiKey: cfg.radarr_api_key },
{ tmdbId: tmdbId ?? undefined, imdbId: imdbId ?? undefined }
);
if (radarrLanguage) {
if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(radarrLanguage)) {
// Conflict: prefer Radarr, flag for review
needsReview = 1;
}
origLang = radarrLanguage;
origLangSource = 'radarr';
}
}
// Cross-check with Sonarr (episodes)
if (jellyfinItem.Type === 'Episode' && sonarrEnabled && tvdbId) {
const sonarrLanguage = await sonarrLang(
{ url: cfg.sonarr_url, apiKey: cfg.sonarr_api_key },
tvdbId
);
if (sonarrLanguage) {
if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(sonarrLanguage)) {
needsReview = 1;
}
origLang = sonarrLanguage;
origLangSource = 'sonarr';
}
}
// Upsert item
upsertItem.run(
jellyfinItem.Id,
jellyfinItem.Type === 'Episode' ? 'Episode' : 'Movie',
jellyfinItem.Name,
jellyfinItem.SeriesName ?? null,
jellyfinItem.SeriesId ?? null,
jellyfinItem.ParentIndexNumber ?? null,
jellyfinItem.IndexNumber ?? null,
jellyfinItem.ProductionYear ?? null,
jellyfinItem.Path ?? '',
jellyfinItem.Size ?? null,
jellyfinItem.Container ?? null,
origLang,
origLangSource,
needsReview,
imdbId,
tmdbId,
tvdbId
);
const itemRow = getItemByJellyfinId.get(jellyfinItem.Id) as { id: number };
const itemId = itemRow.id;
// Upsert streams
deleteStreams.run(itemId);
for (const jStream of jellyfinItem.MediaStreams ?? []) {
const s = mapStream(jStream);
insertStream.run(
itemId,
s.stream_index,
s.type,
s.codec,
s.language,
s.language_display,
s.title,
s.is_default,
s.is_forced,
s.is_hearing_impaired,
s.channels,
s.channel_layout,
s.bit_rate,
s.sample_rate
);
}
// Run analyzer
const streams = getStreamsByItemId.all(itemId) as MediaStream[];
const analysis = analyzeItem(
{ original_language: origLang, needs_review: needsReview },
streams,
{ subtitleLanguages }
);
upsertPlan.run(itemId, analysis.is_noop ? 1 : 0, analysis.notes);
const planRow = getPlanByItemId.get(itemId) as { id: number };
const planId = planRow.id;
for (const dec of analysis.decisions) {
upsertDecision.run(planId, dec.stream_id, dec.action, dec.target_index);
}
emitSse('log', { name: jellyfinItem.Name, type: jellyfinItem.Type, status: 'scanned' });
} catch (err) {
errors++;
console.error(`Error scanning ${jellyfinItem.Name}:`, err);
try {
db.prepare(
"UPDATE media_items SET scan_status = 'error', scan_error = ? WHERE jellyfin_id = ?"
).run(String(err), jellyfinItem.Id);
} catch { /* ignore */ }
emitSse('log', { name: jellyfinItem.Name, type: jellyfinItem.Type, status: 'error' });
}
}
setConfig('scan_running', '0');
emitSse('complete', { scanned, total, errors });
}
export default app;

103
src/api/setup.tsx Normal file
View File

@@ -0,0 +1,103 @@
import { Hono } from 'hono';
import { getConfig, setConfig, getAllConfig } from '../db/index';
import { testConnection as testJellyfin, getUsers } from '../services/jellyfin';
import { testConnection as testRadarr } from '../services/radarr';
import { testConnection as testSonarr } from '../services/sonarr';
import { SetupPage, ConnStatusFragment } from '../views/setup';
const app = new Hono();
app.get('/', async (c) => {
const setupComplete = getConfig('setup_complete') === '1';
if (setupComplete) return c.redirect('/');
const step = Number(c.req.query('step') ?? '1') as 1 | 2 | 3 | 4;
const config = getAllConfig();
return c.html(<SetupPage step={step} config={config} />);
});
app.post('/jellyfin', async (c) => {
const body = await c.req.formData();
const url = (body.get('url') as string)?.replace(/\/$/, '');
const apiKey = body.get('api_key') as string;
if (!url || !apiKey) {
return c.html(<ConnStatusFragment ok={false} error="URL and API key are required" />);
}
const result = await testJellyfin({ url, apiKey, userId: '' });
if (!result.ok) {
return c.html(<ConnStatusFragment ok={false} error={result.error} />);
}
// Auto-discover user ID
let userId = '';
try {
const users = await getUsers({ url, apiKey });
const admin = users.find((u) => u.Name === 'admin') ?? users[0];
userId = admin?.Id ?? '';
} catch {
// Non-fatal; user can enter manually later
}
setConfig('jellyfin_url', url);
setConfig('jellyfin_api_key', apiKey);
if (userId) setConfig('jellyfin_user_id', userId);
return c.html(<ConnStatusFragment ok={true} nextUrl="/setup?step=2" />);
});
app.post('/radarr', async (c) => {
const body = await c.req.formData();
const url = (body.get('url') as string)?.replace(/\/$/, '');
const apiKey = body.get('api_key') as string;
if (!url || !apiKey) {
// Skip was clicked with empty fields — go to next step
return c.redirect('/setup?step=3');
}
const result = await testRadarr({ url, apiKey });
if (!result.ok) {
return c.html(<ConnStatusFragment ok={false} error={result.error} />);
}
setConfig('radarr_url', url);
setConfig('radarr_api_key', apiKey);
setConfig('radarr_enabled', '1');
return c.html(<ConnStatusFragment ok={true} nextUrl="/setup?step=3" />);
});
app.post('/sonarr', async (c) => {
const body = await c.req.formData();
const url = (body.get('url') as string)?.replace(/\/$/, '');
const apiKey = body.get('api_key') as string;
if (!url || !apiKey) {
return c.redirect('/setup?step=4');
}
const result = await testSonarr({ url, apiKey });
if (!result.ok) {
return c.html(<ConnStatusFragment ok={false} error={result.error} />);
}
setConfig('sonarr_url', url);
setConfig('sonarr_api_key', apiKey);
setConfig('sonarr_enabled', '1');
return c.html(<ConnStatusFragment ok={true} nextUrl="/setup?step=4" />);
});
app.post('/complete', async (c) => {
const body = await c.req.formData();
const langs = body.getAll('subtitle_lang') as string[];
if (langs.length > 0) {
setConfig('subtitle_languages', JSON.stringify(langs));
}
setConfig('setup_complete', '1');
return c.redirect('/');
});
export default app;

48
src/db/index.ts Normal file
View File

@@ -0,0 +1,48 @@
import { Database } from 'bun:sqlite';
import { join } from 'node:path';
import { mkdirSync } from 'node:fs';
import { SCHEMA, DEFAULT_CONFIG } from './schema';
const dataDir = process.env.DATA_DIR ?? './data';
mkdirSync(dataDir, { recursive: true });
const dbPath = join(dataDir, 'netfelix.db');
let _db: Database | null = null;
export function getDb(): Database {
if (_db) return _db;
_db = new Database(dbPath, { create: true });
_db.exec(SCHEMA);
seedDefaults(_db);
return _db;
}
function seedDefaults(db: Database): void {
const insert = db.prepare(
'INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)'
);
for (const [key, value] of Object.entries(DEFAULT_CONFIG)) {
insert.run(key, value);
}
}
export function getConfig(key: string): string | null {
const row = getDb()
.prepare('SELECT value FROM config WHERE key = ?')
.get(key) as { value: string } | undefined;
return row?.value ?? null;
}
export function setConfig(key: string, value: string): void {
getDb()
.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)')
.run(key, value);
}
export function getAllConfig(): Record<string, string> {
const rows = getDb()
.prepare('SELECT key, value FROM config')
.all() as { key: string; value: string }[];
return Object.fromEntries(rows.map((r) => [r.key, r.value ?? '']));
}

114
src/db/schema.ts Normal file
View File

@@ -0,0 +1,114 @@
export const SCHEMA = `
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY NOT NULL,
value TEXT
);
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
host TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 22,
username TEXT NOT NULL,
private_key TEXT NOT NULL,
ffmpeg_path TEXT NOT NULL DEFAULT 'ffmpeg',
work_dir TEXT NOT NULL DEFAULT '/tmp',
status TEXT NOT NULL DEFAULT 'unknown',
last_checked_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS media_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jellyfin_id TEXT NOT NULL UNIQUE,
type TEXT NOT NULL,
name TEXT NOT NULL,
series_name TEXT,
series_jellyfin_id TEXT,
season_number INTEGER,
episode_number INTEGER,
year INTEGER,
file_path TEXT NOT NULL,
file_size INTEGER,
container TEXT,
original_language TEXT,
orig_lang_source TEXT,
needs_review INTEGER NOT NULL DEFAULT 1,
imdb_id TEXT,
tmdb_id TEXT,
tvdb_id TEXT,
scan_status TEXT NOT NULL DEFAULT 'pending',
scan_error TEXT,
last_scanned_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS media_streams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
stream_index INTEGER NOT NULL,
type TEXT NOT NULL,
codec TEXT,
language TEXT,
language_display TEXT,
title TEXT,
is_default INTEGER NOT NULL DEFAULT 0,
is_forced INTEGER NOT NULL DEFAULT 0,
is_hearing_impaired INTEGER NOT NULL DEFAULT 0,
channels INTEGER,
channel_layout TEXT,
bit_rate INTEGER,
sample_rate INTEGER,
UNIQUE(item_id, stream_index)
);
CREATE TABLE IF NOT EXISTS review_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL UNIQUE REFERENCES media_items(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending',
is_noop INTEGER NOT NULL DEFAULT 0,
notes TEXT,
reviewed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS stream_decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES review_plans(id) ON DELETE CASCADE,
stream_id INTEGER NOT NULL REFERENCES media_streams(id) ON DELETE CASCADE,
action TEXT NOT NULL,
target_index INTEGER,
UNIQUE(plan_id, stream_id)
);
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
command TEXT NOT NULL,
node_id INTEGER REFERENCES nodes(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'pending',
output TEXT,
exit_code INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
started_at TEXT,
completed_at TEXT
);
`;
export const DEFAULT_CONFIG: Record<string, string> = {
setup_complete: '0',
jellyfin_url: '',
jellyfin_api_key: '',
jellyfin_user_id: '',
radarr_url: '',
radarr_api_key: '',
radarr_enabled: '0',
sonarr_url: '',
sonarr_api_key: '',
sonarr_enabled: '0',
subtitle_languages: JSON.stringify(['eng', 'deu', 'spa']),
scan_running: '0',
};

83
src/server.tsx Normal file
View File

@@ -0,0 +1,83 @@
import { Hono } from 'hono';
import { serveStatic } from 'hono/bun';
import { getDb, getConfig, getAllConfig } from './db/index';
import type { MediaItem } from './types';
import { DashboardPage } from './views/dashboard';
import setupRoutes from './api/setup';
import scanRoutes from './api/scan';
import reviewRoutes from './api/review';
import executeRoutes from './api/execute';
import nodesRoutes from './api/nodes';
const app = new Hono();
// ─── Static assets ────────────────────────────────────────────────────────────
app.use('/app.css', serveStatic({ path: './public/app.css' }));
// ─── Setup guard ──────────────────────────────────────────────────────────────
app.use('*', async (c, next) => {
const path = new URL(c.req.url).pathname;
// Allow setup routes, static assets, and SSE endpoints without setup check
if (
path.startsWith('/setup') ||
path === '/app.css' ||
path.startsWith('/scan/events') ||
path.startsWith('/execute/events')
) {
return next();
}
const setupComplete = getConfig('setup_complete') === '1';
if (!setupComplete) {
return c.redirect('/setup');
}
return next();
});
// ─── Dashboard ────────────────────────────────────────────────────────────────
app.get('/', (c) => {
const db = getDb();
const totalItems = (db.prepare('SELECT COUNT(*) as n FROM media_items').get() as { n: number }).n;
const scanned = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'scanned'").get() as { n: number }).n;
const needsAction = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }).n;
const noChange = (db.prepare('SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1').get() as { n: number }).n;
const approved = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").get() as { n: number }).n;
const done = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'done'").get() as { n: number }).n;
const errors = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'error'").get() as { n: number }).n;
const scanRunning = getConfig('scan_running') === '1';
return c.html(
<DashboardPage
stats={{ totalItems, scanned, needsAction, approved, done, errors, noChange }}
scanRunning={scanRunning}
/>
);
});
// ─── Routes ───────────────────────────────────────────────────────────────────
app.route('/setup', setupRoutes);
app.route('/scan', scanRoutes);
app.route('/review', reviewRoutes);
app.route('/execute', executeRoutes);
app.route('/nodes', nodesRoutes);
// ─── Start server ─────────────────────────────────────────────────────────────
const port = Number(process.env.PORT ?? '3000');
console.log(`netfelix-audio-fix starting on http://localhost:${port}`);
// Initialize DB on startup
getDb();
export default {
port,
fetch: app.fetch,
};

186
src/services/analyzer.ts Normal file
View File

@@ -0,0 +1,186 @@
import type { MediaItem, MediaStream, PlanResult } from '../types';
import { normalizeLanguage } from './jellyfin';
export interface AnalyzerConfig {
subtitleLanguages: string[]; // ISO 639-2 codes to keep
}
const DEFAULT_SUBTITLE_ORDER: Record<string, number> = {
eng: 0,
deu: 1,
spa: 2,
};
/**
* Given an item and its streams, compute what action to take for each stream
* and whether the file needs remuxing at all.
*/
export function analyzeItem(
item: Pick<MediaItem, 'original_language' | 'needs_review'>,
streams: MediaStream[],
config: AnalyzerConfig
): PlanResult {
const origLang = item.original_language ? normalizeLanguage(item.original_language) : null;
const keepSubLangs = new Set(config.subtitleLanguages.map(normalizeLanguage));
const notes: string[] = [];
// Compute action for each stream
const decisions: PlanResult['decisions'] = streams.map((s) => {
const action = decideAction(s, origLang, keepSubLangs);
return { stream_id: s.id, action, target_index: null };
});
// Check if any stream is being removed
const anyRemoved = decisions.some((d) => d.action === 'remove');
// Compute target ordering for kept streams within type groups
const keptStreams = streams.filter((_, i) => decisions[i].action === 'keep');
assignTargetOrder(keptStreams, decisions, streams, origLang);
// Check if ordering changes (compare current order vs target order of kept streams)
const orderChanged = checkOrderChanged(streams, decisions);
const isNoop = !anyRemoved && !orderChanged;
// Generate notes for edge cases
if (!origLang && item.needs_review) {
notes.push('Original language unknown — audio tracks not filtered; manual review required');
}
const mp4SubIssue = checkMp4SubtitleIssue(item as MediaItem, streams, decisions);
if (mp4SubIssue) notes.push(mp4SubIssue);
return {
is_noop: isNoop,
decisions,
notes: notes.length > 0 ? notes.join('\n') : null,
};
}
function decideAction(
stream: MediaStream,
origLang: string | null,
keepSubLangs: Set<string>
): 'keep' | 'remove' {
switch (stream.type) {
case 'Video':
case 'Data':
case 'EmbeddedImage':
return 'keep';
case 'Audio': {
if (!origLang) return 'keep'; // unknown lang → keep all
if (!stream.language) return 'keep'; // undetermined → keep
return normalizeLanguage(stream.language) === origLang ? 'keep' : 'remove';
}
case 'Subtitle': {
if (stream.is_forced) return 'keep';
if (stream.is_hearing_impaired) return 'keep';
if (!stream.language) return 'remove'; // undetermined subtitle → remove
return keepSubLangs.has(normalizeLanguage(stream.language)) ? 'keep' : 'remove';
}
default:
return 'keep';
}
}
function assignTargetOrder(
keptStreams: MediaStream[],
decisions: PlanResult['decisions'],
allStreams: MediaStream[],
origLang: string | null
): void {
// Group kept streams by type
const byType: Record<string, MediaStream[]> = {};
for (const s of keptStreams) {
const t = s.type;
byType[t] = byType[t] ?? [];
byType[t].push(s);
}
// Sort audio: original lang first, then by stream_index
if (byType['Audio']) {
byType['Audio'].sort((a, b) => {
const aIsOrig = origLang && a.language && normalizeLanguage(a.language) === origLang ? 0 : 1;
const bIsOrig = origLang && b.language && normalizeLanguage(b.language) === origLang ? 0 : 1;
if (aIsOrig !== bIsOrig) return aIsOrig - bIsOrig;
return a.stream_index - b.stream_index;
});
}
// Sort subtitles: eng → deu → spa → forced → CC → rest
if (byType['Subtitle']) {
byType['Subtitle'].sort((a, b) => {
const aOrder = subtitleSortKey(a);
const bOrder = subtitleSortKey(b);
if (aOrder !== bOrder) return aOrder - bOrder;
return a.stream_index - b.stream_index;
});
}
// Assign target_index per type group
for (const [, typeStreams] of Object.entries(byType)) {
typeStreams.forEach((s, idx) => {
const dec = decisions.find((d) => d.stream_id === s.id);
if (dec) dec.target_index = idx;
});
}
}
function subtitleSortKey(s: MediaStream): number {
if (s.is_forced) return 90;
if (s.is_hearing_impaired) return 95;
if (!s.language) return 99;
const lang = normalizeLanguage(s.language);
return DEFAULT_SUBTITLE_ORDER[lang] ?? 50;
}
function checkOrderChanged(
streams: MediaStream[],
decisions: PlanResult['decisions']
): boolean {
// Build ordered list of kept streams by their target_index within each type group
// Compare against their current stream_index positions
const kept = streams.filter((s) => {
const dec = decisions.find((d) => d.stream_id === s.id);
return dec?.action === 'keep';
});
// Per type, check if target_index matches current relative position
const byType: Record<string, MediaStream[]> = {};
for (const s of kept) {
byType[s.type] = byType[s.type] ?? [];
byType[s.type].push(s);
}
for (const typeStreams of Object.values(byType)) {
const sorted = [...typeStreams].sort((a, b) => a.stream_index - b.stream_index);
for (let i = 0; i < typeStreams.length; i++) {
const dec = decisions.find((d) => d.stream_id === typeStreams[i].id);
if (!dec) continue;
// Check if target_index matches position in sorted list
const currentPos = sorted.findIndex((s) => s.id === typeStreams[i].id);
if (dec.target_index !== null && dec.target_index !== currentPos) return true;
}
}
return false;
}
function checkMp4SubtitleIssue(
item: MediaItem,
streams: MediaStream[],
decisions: PlanResult['decisions']
): string | null {
if (!item.container || item.container.toLowerCase() !== 'mp4') return null;
const incompatibleCodecs = new Set(['hdmv_pgs_subtitle', 'pgssub', 'dvd_subtitle', 'ass', 'ssa']);
const keptSubtitles = streams.filter((s) => {
if (s.type !== 'Subtitle') return false;
const dec = decisions.find((d) => d.stream_id === s.id);
return dec?.action === 'keep';
});
const bad = keptSubtitles.filter((s) => s.codec && incompatibleCodecs.has(s.codec.toLowerCase()));
if (bad.length === 0) return null;
return `MP4 container with incompatible subtitle codec(s): ${bad.map((s) => s.codec).join(', ')} — consider converting to MKV`;
}

124
src/services/ffmpeg.ts Normal file
View File

@@ -0,0 +1,124 @@
import type { MediaItem, MediaStream, StreamDecision } from '../types';
import { normalizeLanguage } from './jellyfin';
/**
* Build the full shell command to remux a media file, keeping only the
* streams specified by the decisions and in the target order.
*
* Returns null if all streams are kept and ordering is unchanged (noop).
*/
export function buildCommand(
item: MediaItem,
streams: MediaStream[],
decisions: StreamDecision[]
): string {
// Sort kept streams by type priority then target_index
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 }[];
// Sort: Video first, Audio second, Subtitle third, Data last
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 inputPath = item.file_path;
const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv';
const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
const maps = kept.map((k) => `-map 0:${k.stream.stream_index}`);
const parts: string[] = [
'ffmpeg',
'-y',
'-i', shellQuote(inputPath),
...maps,
'-c copy',
shellQuote(tmpPath),
'&&',
'mv', shellQuote(tmpPath), shellQuote(inputPath),
];
return parts.join(' ');
}
/**
* Build a command that also changes the container to MKV.
* Used when MP4 container can't hold certain subtitle codecs.
*/
export function buildMkvConvertCommand(
item: MediaItem,
streams: MediaStream[],
decisions: StreamDecision[]
): string {
const inputPath = item.file_path;
const outputPath = inputPath.replace(/\.[^.]+$/, '.mkv');
const tmpPath = inputPath.replace(/\.[^.]+$/, '.tmp.mkv');
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 };
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 = kept.map((k) => `-map 0:${k.stream.stream_index}`);
return [
'ffmpeg', '-y',
'-i', shellQuote(inputPath),
...maps,
'-c copy',
'-f matroska',
shellQuote(tmpPath),
'&&',
'mv', shellQuote(tmpPath), shellQuote(outputPath),
].join(' ');
}
/** Safely quote a path for shell usage. */
export function shellQuote(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`;
}
/** Returns a human-readable summary of what will change. */
export function summarizeChanges(
streams: MediaStream[],
decisions: StreamDecision[]
): { removed: MediaStream[]; kept: MediaStream[] } {
const removed: MediaStream[] = [];
const kept: MediaStream[] = [];
for (const s of streams) {
const dec = decisions.find((d) => d.stream_id === s.id);
if (!dec || dec.action === 'remove') removed.push(s);
else kept.push(s);
}
return { removed, kept };
}
/** Format a stream for display. */
export function streamLabel(s: MediaStream): string {
const parts: string[] = [s.type];
if (s.codec) parts.push(s.codec);
if (s.language_display || s.language) parts.push(s.language_display ?? s.language!);
if (s.title) parts.push(`"${s.title}"`);
if (s.type === 'Audio' && s.channels) parts.push(`${s.channels}ch`);
if (s.is_forced) parts.push('forced');
if (s.is_hearing_impaired) parts.push('CC');
return parts.join(' · ');
}

151
src/services/jellyfin.ts Normal file
View File

@@ -0,0 +1,151 @@
import type { JellyfinItem, JellyfinMediaStream, JellyfinUser, MediaStream } from '../types';
export interface JellyfinConfig {
url: string;
apiKey: string;
userId: string;
}
const PAGE_SIZE = 200;
function headers(apiKey: string): Record<string, string> {
return {
'X-Emby-Token': apiKey,
'Content-Type': 'application/json',
};
}
export async function testConnection(cfg: JellyfinConfig): Promise<{ ok: boolean; error?: string }> {
try {
const res = await fetch(`${cfg.url}/Users`, {
headers: headers(cfg.apiKey),
});
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
return { ok: true };
} catch (e) {
return { ok: false, error: String(e) };
}
}
export async function getUsers(cfg: Pick<JellyfinConfig, 'url' | 'apiKey'>): Promise<JellyfinUser[]> {
const res = await fetch(`${cfg.url}/Users`, { headers: headers(cfg.apiKey) });
if (!res.ok) throw new Error(`Jellyfin /Users failed: ${res.status}`);
return res.json() as Promise<JellyfinUser[]>;
}
export async function* getAllItems(
cfg: JellyfinConfig,
onProgress?: (count: number, total: number) => void
): AsyncGenerator<JellyfinItem> {
const fields = [
'MediaStreams',
'Path',
'ProviderIds',
'OriginalTitle',
'ProductionYear',
'Size',
'Container',
].join(',');
let startIndex = 0;
let total = 0;
do {
const url = new URL(`${cfg.url}/Users/${cfg.userId}/Items`);
url.searchParams.set('Recursive', 'true');
url.searchParams.set('IncludeItemTypes', 'Movie,Episode');
url.searchParams.set('Fields', fields);
url.searchParams.set('Limit', String(PAGE_SIZE));
url.searchParams.set('StartIndex', String(startIndex));
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
if (!res.ok) throw new Error(`Jellyfin items failed: ${res.status}`);
const body = (await res.json()) as { Items: JellyfinItem[]; TotalRecordCount: number };
total = body.TotalRecordCount;
for (const item of body.Items) {
yield item;
}
startIndex += body.Items.length;
onProgress?.(startIndex, total);
} while (startIndex < total);
}
/** Map a Jellyfin item to our normalized language code (ISO 639-2). */
export function extractOriginalLanguage(item: JellyfinItem): string | null {
// Jellyfin doesn't have a direct "original_language" field like TMDb.
// The best proxy is the language of the first audio stream.
if (!item.MediaStreams) return null;
const firstAudio = item.MediaStreams.find((s) => s.Type === 'Audio');
return firstAudio?.Language ? normalizeLanguage(firstAudio.Language) : null;
}
/** Map a Jellyfin MediaStream to our internal MediaStream shape (sans id/item_id). */
export function mapStream(s: JellyfinMediaStream): Omit<MediaStream, 'id' | 'item_id'> {
return {
stream_index: s.Index,
type: s.Type as MediaStream['type'],
codec: s.Codec ?? null,
language: s.Language ? normalizeLanguage(s.Language) : null,
language_display: s.DisplayLanguage ?? null,
title: s.Title ?? null,
is_default: s.IsDefault ? 1 : 0,
is_forced: s.IsForced ? 1 : 0,
is_hearing_impaired: s.IsHearingImpaired ? 1 : 0,
channels: s.Channels ?? null,
channel_layout: s.ChannelLayout ?? null,
bit_rate: s.BitRate ?? null,
sample_rate: s.SampleRate ?? null,
};
}
// ISO 639-2/T → ISO 639-2/B normalization + common aliases
const LANG_ALIASES: Record<string, string> = {
// German: both /T (deu) and /B (ger) → deu
ger: 'deu',
// Chinese
chi: 'zho',
// French
fre: 'fra',
// Dutch
dut: 'nld',
// Modern Greek
gre: 'ell',
// Hebrew
heb: 'heb',
// Farsi
per: 'fas',
// Romanian
rum: 'ron',
// Malay
may: 'msa',
// Tibetan
tib: 'bod',
// Burmese
bur: 'mya',
// Czech
cze: 'ces',
// Slovak
slo: 'slk',
// Georgian
geo: 'kat',
// Icelandic
ice: 'isl',
// Armenian
arm: 'hye',
// Basque
baq: 'eus',
// Albanian
alb: 'sqi',
// Macedonian
mac: 'mkd',
// Welsh
wel: 'cym',
};
export function normalizeLanguage(lang: string): string {
const lower = lang.toLowerCase().trim();
return LANG_ALIASES[lower] ?? lower;
}

108
src/services/radarr.ts Normal file
View File

@@ -0,0 +1,108 @@
import { normalizeLanguage } from './jellyfin';
export interface RadarrConfig {
url: string;
apiKey: string;
}
function headers(apiKey: string): Record<string, string> {
return { 'X-Api-Key': apiKey };
}
export async function testConnection(cfg: RadarrConfig): Promise<{ ok: boolean; error?: string }> {
try {
const res = await fetch(`${cfg.url}/api/v3/system/status`, {
headers: headers(cfg.apiKey),
});
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
return { ok: true };
} catch (e) {
return { ok: false, error: String(e) };
}
}
interface RadarrMovie {
tmdbId?: number;
imdbId?: string;
originalLanguage?: { name: string; nameTranslated?: string };
}
/** Returns ISO 639-2 original language or null. */
export async function getOriginalLanguage(
cfg: RadarrConfig,
ids: { tmdbId?: string; imdbId?: string }
): Promise<string | null> {
try {
let movie: RadarrMovie | null = null;
if (ids.tmdbId) {
const res = await fetch(`${cfg.url}/api/v3/movie?tmdbId=${ids.tmdbId}`, {
headers: headers(cfg.apiKey),
});
if (res.ok) {
const list = (await res.json()) as RadarrMovie[];
movie = list[0] ?? null;
}
}
if (!movie && ids.imdbId) {
const res = await fetch(`${cfg.url}/api/v3/movie`, {
headers: headers(cfg.apiKey),
});
if (res.ok) {
const list = (await res.json()) as (RadarrMovie & { imdbId?: string })[];
movie = list.find((m) => m.imdbId === ids.imdbId) ?? null;
}
}
if (!movie?.originalLanguage) return null;
return iso6391To6392(movie.originalLanguage.name) ?? null;
} catch {
return null;
}
}
// Radarr returns language names like "English", "French", "German", etc.
// Map them to ISO 639-2 codes.
const NAME_TO_639_2: Record<string, string> = {
english: 'eng',
french: 'fra',
german: 'deu',
spanish: 'spa',
italian: 'ita',
portuguese: 'por',
japanese: 'jpn',
korean: 'kor',
chinese: 'zho',
arabic: 'ara',
russian: 'rus',
dutch: 'nld',
swedish: 'swe',
norwegian: 'nor',
danish: 'dan',
finnish: 'fin',
polish: 'pol',
turkish: 'tur',
thai: 'tha',
hindi: 'hin',
hungarian: 'hun',
czech: 'ces',
romanian: 'ron',
greek: 'ell',
hebrew: 'heb',
persian: 'fas',
ukrainian: 'ukr',
indonesian: 'ind',
malay: 'msa',
vietnamese: 'vie',
catalan: 'cat',
tamil: 'tam',
telugu: 'tel',
'brazilian portuguese': 'por',
'portuguese (brazil)': 'por',
};
function iso6391To6392(name: string): string | null {
const key = name.toLowerCase().trim();
return NAME_TO_639_2[key] ?? normalizeLanguage(key.slice(0, 3)) ?? null;
}

85
src/services/sonarr.ts Normal file
View File

@@ -0,0 +1,85 @@
import { normalizeLanguage } from './jellyfin';
export interface SonarrConfig {
url: string;
apiKey: string;
}
function headers(apiKey: string): Record<string, string> {
return { 'X-Api-Key': apiKey };
}
export async function testConnection(cfg: SonarrConfig): Promise<{ ok: boolean; error?: string }> {
try {
const res = await fetch(`${cfg.url}/api/v3/system/status`, {
headers: headers(cfg.apiKey),
});
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
return { ok: true };
} catch (e) {
return { ok: false, error: String(e) };
}
}
interface SonarrSeries {
tvdbId?: number;
originalLanguage?: { name: string };
}
/** Returns ISO 639-2 original language for a series or null. */
export async function getOriginalLanguage(
cfg: SonarrConfig,
tvdbId: string
): Promise<string | null> {
try {
const res = await fetch(`${cfg.url}/api/v3/series?tvdbId=${tvdbId}`, {
headers: headers(cfg.apiKey),
});
if (!res.ok) return null;
const list = (await res.json()) as SonarrSeries[];
const series = list[0];
if (!series?.originalLanguage) return null;
return languageNameToCode(series.originalLanguage.name) ?? null;
} catch {
return null;
}
}
const NAME_TO_639_2: Record<string, string> = {
english: 'eng',
french: 'fra',
german: 'deu',
spanish: 'spa',
italian: 'ita',
portuguese: 'por',
japanese: 'jpn',
korean: 'kor',
chinese: 'zho',
arabic: 'ara',
russian: 'rus',
dutch: 'nld',
swedish: 'swe',
norwegian: 'nor',
danish: 'dan',
finnish: 'fin',
polish: 'pol',
turkish: 'tur',
thai: 'tha',
hindi: 'hin',
hungarian: 'hun',
czech: 'ces',
romanian: 'ron',
greek: 'ell',
hebrew: 'heb',
persian: 'fas',
ukrainian: 'ukr',
indonesian: 'ind',
malay: 'msa',
vietnamese: 'vie',
};
function languageNameToCode(name: string): string | null {
const key = name.toLowerCase().trim();
return NAME_TO_639_2[key] ?? normalizeLanguage(key.slice(0, 3)) ?? null;
}

163
src/services/ssh.ts Normal file
View File

@@ -0,0 +1,163 @@
import { Client } from 'ssh2';
import type { Node } from '../types';
export interface ExecResult {
exitCode: number;
output: string;
}
/** Test SSH connectivity to a node. Returns ok + optional error message. */
export function testConnection(node: Node): Promise<{ ok: boolean; error?: string }> {
return new Promise((resolve) => {
const conn = new Client();
const timeout = setTimeout(() => {
conn.destroy();
resolve({ ok: false, error: 'Connection timed out' });
}, 10_000);
conn.on('ready', () => {
clearTimeout(timeout);
conn.exec('echo ok', (err, stream) => {
if (err) {
conn.end();
resolve({ ok: false, error: err.message });
return;
}
stream.on('close', () => {
conn.end();
resolve({ ok: true });
});
});
});
conn.on('error', (err) => {
clearTimeout(timeout);
resolve({ ok: false, error: err.message });
});
conn.connect(buildConnectConfig(node));
});
}
/**
* Execute a command on a remote node and stream output lines.
* Yields lines as they arrive. Throws if connection fails.
*/
export async function* execStream(
node: Node,
command: string
): AsyncGenerator<string> {
// Collect lines via a promise-based queue
const queue: string[] = [];
const resolvers: Array<(value: IteratorResult<string>) => void> = [];
let done = false;
let errorVal: Error | null = null;
const push = (line: string) => {
if (resolvers.length > 0) {
resolvers.shift()!({ value: line, done: false });
} else {
queue.push(line);
}
};
const finish = (err?: Error) => {
done = true;
errorVal = err ?? null;
while (resolvers.length > 0) {
resolvers.shift()!({ value: undefined as unknown as string, done: true });
}
};
const conn = new Client();
conn.on('ready', () => {
conn.exec(command, { pty: false }, (err, stream) => {
if (err) {
conn.end();
finish(err);
return;
}
stream.stdout.on('data', (data: Buffer) => {
const lines = data.toString('utf8').split('\n');
for (const line of lines) {
if (line) push(line);
}
});
stream.stderr.on('data', (data: Buffer) => {
const lines = data.toString('utf8').split('\n');
for (const line of lines) {
if (line) push(`[stderr] ${line}`);
}
});
stream.on('close', (code: number) => {
if (code !== 0) {
push(`[exit code ${code}]`);
}
conn.end();
finish();
});
});
});
conn.on('error', (err) => finish(err));
conn.connect(buildConnectConfig(node));
// Yield from the queue
while (true) {
if (queue.length > 0) {
yield queue.shift()!;
} else if (done) {
if (errorVal) throw errorVal;
return;
} else {
await new Promise<IteratorResult<string>>((resolve) => {
resolvers.push(resolve);
});
}
}
}
/**
* Execute a command on a remote node and return full output + exit code.
* For use when streaming isn't needed.
*/
export function execOnce(node: Node, command: string): Promise<ExecResult> {
return new Promise((resolve, reject) => {
const conn = new Client();
let output = '';
conn.on('ready', () => {
conn.exec(command, (err, stream) => {
if (err) {
conn.end();
reject(err);
return;
}
stream.stdout.on('data', (d: Buffer) => { output += d.toString(); });
stream.stderr.on('data', (d: Buffer) => { output += d.toString(); });
stream.on('close', (code: number) => {
conn.end();
resolve({ exitCode: code ?? 0, output });
});
});
});
conn.on('error', reject);
conn.connect(buildConnectConfig(node));
});
}
function buildConnectConfig(node: Node): Parameters<Client['connect']>[0] {
return {
host: node.host,
port: node.port,
username: node.username,
privateKey: node.private_key,
readyTimeout: 10_000,
};
}

160
src/types.ts Normal file
View File

@@ -0,0 +1,160 @@
// ─── Database row types ───────────────────────────────────────────────────────
export interface MediaItem {
id: number;
jellyfin_id: string;
type: 'Movie' | 'Episode';
name: string;
series_name: string | null;
series_jellyfin_id: string | null;
season_number: number | null;
episode_number: number | null;
year: number | null;
file_path: string;
file_size: number | null;
container: string | null;
original_language: string | null;
orig_lang_source: 'jellyfin' | 'radarr' | 'sonarr' | 'manual' | null;
needs_review: number;
imdb_id: string | null;
tmdb_id: string | null;
tvdb_id: string | null;
scan_status: 'pending' | 'scanned' | 'error';
scan_error: string | null;
last_scanned_at: string | null;
created_at: string;
}
export interface MediaStream {
id: number;
item_id: number;
stream_index: number;
type: 'Video' | 'Audio' | 'Subtitle' | 'Data' | 'EmbeddedImage';
codec: string | null;
language: string | null;
language_display: string | null;
title: string | null;
is_default: number;
is_forced: number;
is_hearing_impaired: number;
channels: number | null;
channel_layout: string | null;
bit_rate: number | null;
sample_rate: number | null;
}
export interface ReviewPlan {
id: number;
item_id: number;
status: 'pending' | 'approved' | 'skipped' | 'done' | 'error';
is_noop: number;
notes: string | null;
reviewed_at: string | null;
created_at: string;
}
export interface StreamDecision {
id: number;
plan_id: number;
stream_id: number;
action: 'keep' | 'remove';
target_index: number | null;
}
export interface Job {
id: number;
item_id: number;
command: string;
node_id: number | null;
status: 'pending' | 'running' | 'done' | 'error';
output: string | null;
exit_code: number | null;
created_at: string;
started_at: string | null;
completed_at: string | null;
}
export interface Node {
id: number;
name: string;
host: string;
port: number;
username: string;
private_key: string;
ffmpeg_path: string;
work_dir: string;
status: 'unknown' | 'ok' | 'error';
last_checked_at: string | null;
created_at: string;
}
// ─── Analyzer types ───────────────────────────────────────────────────────────
export interface StreamWithDecision extends MediaStream {
action: 'keep' | 'remove';
target_index: number | null;
}
export interface PlanResult {
is_noop: boolean;
decisions: Array<{ stream_id: number; action: 'keep' | 'remove'; target_index: number | null }>;
notes: string | null;
}
// ─── Jellyfin API types ───────────────────────────────────────────────────────
export interface JellyfinMediaStream {
Type: string;
Index: number;
Codec?: string;
Language?: string;
DisplayLanguage?: string;
Title?: string;
IsDefault?: boolean;
IsForced?: boolean;
IsHearingImpaired?: boolean;
Channels?: number;
ChannelLayout?: string;
BitRate?: number;
SampleRate?: number;
}
export interface JellyfinItem {
Id: string;
Type: string;
Name: string;
SeriesName?: string;
SeriesId?: string;
ParentIndexNumber?: number;
IndexNumber?: number;
ProductionYear?: number;
Path?: string;
Size?: number;
Container?: string;
MediaStreams?: JellyfinMediaStream[];
ProviderIds?: Record<string, string>;
}
export interface JellyfinUser {
Id: string;
Name: string;
}
// ─── Scan state ───────────────────────────────────────────────────────────────
export interface ScanProgress {
total: number;
scanned: number;
current_item: string;
errors: number;
running: boolean;
}
// ─── SSE event helpers ────────────────────────────────────────────────────────
export type SseEventType = 'progress' | 'log' | 'complete' | 'error';
export interface SseEvent {
type: SseEventType;
data: unknown;
}

74
src/views/dashboard.tsx Normal file
View File

@@ -0,0 +1,74 @@
import type { FC } from 'hono/jsx';
import { Layout } from './layout';
interface DashboardProps {
stats: {
totalItems: number;
scanned: number;
needsAction: number;
approved: number;
done: number;
errors: number;
noChange: number;
};
scanRunning: boolean;
}
export const DashboardPage: FC<DashboardProps> = ({ stats, scanRunning }) => (
<Layout title="Dashboard" activeNav="dashboard">
<div class="page-header">
<h1>Dashboard</h1>
</div>
<div class="stat-grid">
<div class="stat-card">
<div class="num">{stats.totalItems.toLocaleString()}</div>
<div class="label">Total items</div>
</div>
<div class="stat-card">
<div class="num">{stats.scanned.toLocaleString()}</div>
<div class="label">Scanned</div>
</div>
<div class="stat-card">
<div class="num">{stats.needsAction.toLocaleString()}</div>
<div class="label">Needs action</div>
</div>
<div class="stat-card">
<div class="num">{stats.noChange.toLocaleString()}</div>
<div class="label">No change needed</div>
</div>
<div class="stat-card">
<div class="num">{stats.approved.toLocaleString()}</div>
<div class="label">Approved / queued</div>
</div>
<div class="stat-card">
<div class="num">{stats.done.toLocaleString()}</div>
<div class="label">Done</div>
</div>
{stats.errors > 0 && (
<div class="stat-card">
<div class="num" style="color:var(--color-error)">{stats.errors.toLocaleString()}</div>
<div class="label">Errors</div>
</div>
)}
</div>
<div class="flex-row" style="gap:0.75rem;margin-bottom:2rem;">
{scanRunning ? (
<a href="/scan" role="button" class="secondary"> Scan running</a>
) : (
<form method="post" action="/scan/start" style="display:inline">
<button type="submit"> Start Scan</button>
</form>
)}
<a href="/review" role="button" class="secondary">Review changes</a>
<a href="/execute" role="button" class="secondary">Execute jobs</a>
</div>
{stats.scanned === 0 && (
<div class="alert alert-info">
Library not scanned yet. Click <strong>Start Scan</strong> to begin.
</div>
)}
</Layout>
);

182
src/views/execute.tsx Normal file
View File

@@ -0,0 +1,182 @@
import type { FC } from 'hono/jsx';
import { Layout } from './layout';
import type { Job, Node, MediaItem } from '../types';
interface ExecutePageProps {
jobs: Array<{
job: Job;
item: MediaItem | null;
node: Node | null;
}>;
nodes: Node[];
}
export const ExecutePage: FC<ExecutePageProps> = ({ jobs, nodes }) => {
const pending = jobs.filter((j) => j.job.status === 'pending').length;
const running = jobs.filter((j) => j.job.status === 'running').length;
const done = jobs.filter((j) => j.job.status === 'done').length;
const errors = jobs.filter((j) => j.job.status === 'error').length;
const hasActiveJobs = running > 0;
return (
<Layout title="Execute" activeNav="execute">
<div class="page-header">
<h1>Execute Jobs</h1>
</div>
{/* Stats row */}
<div class="flex-row" style="gap:0.75rem;margin-bottom:1.5rem;flex-wrap:wrap;">
<span class="badge badge-pending">{pending} pending</span>
{running > 0 && <span class="badge badge-running">{running} running</span>}
{done > 0 && <span class="badge badge-done">{done} done</span>}
{errors > 0 && <span class="badge badge-error">{errors} error(s)</span>}
</div>
{/* Controls */}
<div class="flex-row" style="margin-bottom:1.5rem;gap:0.75rem;">
{pending > 0 && (
<form method="post" action="/execute/start" style="display:inline">
<button type="submit"> Run all pending</button>
</form>
)}
{jobs.length === 0 && (
<p class="muted">No jobs yet. Go to <a href="/review">Review</a> and approve items first.</p>
)}
</div>
{/* Job table */}
{jobs.length > 0 && (
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Item</th>
<th>Command</th>
<th>Node</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{jobs.map(({ job, item, node }) => (
<JobRow key={job.id} job={job} item={item} node={node} nodes={nodes} />
))}
</tbody>
</table>
)}
{/* SSE for live updates */}
{hasActiveJobs && (
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
var es = new EventSource('/execute/events');
es.addEventListener('job_update', function(e) {
var d = JSON.parse(e.data);
var row = document.getElementById('job-row-' + d.id);
if (!row) return;
var statusCell = row.querySelector('.job-status');
if (statusCell) statusCell.innerHTML = '<span class="badge badge-' + d.status + '">' + d.status + '</span>';
var logCell = document.getElementById('job-log-' + d.id);
if (logCell && d.output) logCell.textContent = d.output;
});
es.addEventListener('complete', function() { es.close(); location.reload(); });
})();
`,
}}
/>
)}
</Layout>
);
};
const JobRow: FC<{ job: Job; item: MediaItem | null; node: Node | null; nodes: Node[] }> = ({
job,
item,
node,
nodes,
}) => {
const itemName = item
? (item.type === 'Episode' && item.series_name
? `${item.series_name} S${String(item.season_number ?? 0).padStart(2, '0')}E${String(item.episode_number ?? 0).padStart(2, '0')}`
: item.name)
: `Item #${job.item_id}`;
const cmdShort = job.command.length > 80 ? job.command.slice(0, 77) + '…' : job.command;
return (
<>
<tr id={`job-row-${job.id}`}>
<td class="mono">{job.id}</td>
<td>
<div class="truncate" style="max-width:200px;" title={itemName}>{itemName}</div>
{item?.file_path && (
<div class="mono muted truncate" style="font-size:0.72rem;max-width:200px;" title={item.file_path}>
{item.file_path.split('/').pop()}
</div>
)}
</td>
<td class="mono" style="font-size:0.75rem;max-width:300px;">
<span title={job.command}>{cmdShort}</span>
</td>
<td>
{job.status === 'pending' ? (
<form
hx-post={`/execute/job/${job.id}/assign`}
hx-target={`#job-row-${job.id}`}
hx-swap="outerHTML"
style="display:inline"
>
<select name="node_id" style="font-size:0.8rem;padding:0.2em 0.4em;" onchange="this.form.submit()">
<option value="">Local</option>
{nodes.map((n) => (
<option key={n.id} value={n.id} selected={node?.id === n.id}>
{n.name} ({n.host})
</option>
))}
</select>
</form>
) : (
<span class="muted">{node?.name ?? 'Local'}</span>
)}
</td>
<td>
<span class={`badge badge-${job.status} job-status`}>{job.status}</span>
{job.exit_code != null && job.exit_code !== 0 && (
<span class="badge badge-error">exit {job.exit_code}</span>
)}
</td>
<td class="actions-col">
{job.status === 'pending' && (
<form method="post" action={`/execute/job/${job.id}/run`} style="display:inline">
<button type="submit" data-size="sm"> Run</button>
</form>
)}
{job.status === 'pending' && (
<form method="post" action={`/execute/job/${job.id}/cancel`} style="display:inline">
<button type="submit" class="secondary" data-size="sm"></button>
</form>
)}
{(job.status === 'done' || job.status === 'error') && job.output && (
<button
data-size="sm"
class="secondary"
onclick={`document.getElementById('job-log-${job.id}').toggleAttribute('hidden')`}
>
Log
</button>
)}
</td>
</tr>
{job.output && (
<tr>
<td colspan={6} style="padding:0;">
<div id={`job-log-${job.id}`} hidden class="log-output">{job.output}</div>
</td>
</tr>
)}
</>
);
};

38
src/views/layout.tsx Normal file
View File

@@ -0,0 +1,38 @@
import type { FC, PropsWithChildren } from 'hono/jsx';
interface LayoutProps {
title?: string;
activeNav?: 'dashboard' | 'scan' | 'review' | 'execute' | 'nodes' | 'setup';
}
export const Layout: FC<PropsWithChildren<LayoutProps>> = ({ children, title = 'netfelix-audio-fix', activeNav }) => (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} netfelix-audio-fix</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
/>
<link rel="stylesheet" href="/app.css" />
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js" defer />
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer />
</head>
<body>
<nav class="app-nav">
<a href="/" class="brand">🎬 netfelix-audio-fix</a>
<a href="/scan" class={activeNav === 'scan' ? 'active' : ''}>Scan</a>
<a href="/review" class={activeNav === 'review' ? 'active' : ''}>Review</a>
<a href="/execute" class={activeNav === 'execute' ? 'active' : ''}>Execute</a>
<div class="spacer" />
<a href="/nodes" class={activeNav === 'nodes' ? 'active' : ''}>Nodes</a>
<a href="/setup" class={activeNav === 'setup' ? 'active' : ''}>Setup</a>
</nav>
<main class="page">{children}</main>
</body>
</html>
);
/** Render an HTML fragment suitable for HTMX partial swap. */
export const Fragment: FC<PropsWithChildren> = ({ children }) => <>{children}</>;

130
src/views/nodes.tsx Normal file
View File

@@ -0,0 +1,130 @@
import type { FC } from 'hono/jsx';
import { Layout } from './layout';
import type { Node } from '../types';
interface NodesPageProps {
nodes: Node[];
}
export const NodesPage: FC<NodesPageProps> = ({ nodes }) => (
<Layout title="Nodes" activeNav="nodes">
<div class="page-header">
<h1>Remote Nodes</h1>
</div>
<p class="muted">
Remote nodes run FFmpeg over SSH on shared storage. The path to the media file must be
identical on both this server and the remote node.
</p>
{/* Add node form */}
<article>
<header><strong>Add Node</strong></header>
<form
hx-post="/nodes"
hx-target="#nodes-list"
hx-swap="outerHTML"
hx-encoding="multipart/form-data"
>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
<label>
Name
<input type="text" name="name" placeholder="my-server" required />
</label>
<label>
Host
<input type="text" name="host" placeholder="192.168.1.200" required />
</label>
<label>
SSH Port
<input type="number" name="port" value="22" min="1" max="65535" />
</label>
<label>
Username
<input type="text" name="username" placeholder="root" required />
</label>
<label>
FFmpeg path
<input type="text" name="ffmpeg_path" value="ffmpeg" />
</label>
<label>
Work directory
<input type="text" name="work_dir" value="/tmp" />
</label>
</div>
<label>
Private key (PEM)
<input type="file" name="private_key" accept=".pem,.key,text/plain" required />
<small>Upload your SSH private key file. Stored securely in the database.</small>
</label>
<button type="submit">Add Node</button>
</form>
</article>
{/* Node list */}
<NodesList nodes={nodes} />
</Layout>
);
export const NodesList: FC<{ nodes: Node[] }> = ({ nodes }) => (
<div id="nodes-list">
{nodes.length === 0 ? (
<p class="muted">No nodes configured. Add one above.</p>
) : (
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Host</th>
<th>Port</th>
<th>User</th>
<th>FFmpeg</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{nodes.map((node) => (
<tr key={node.id} id={`node-row-${node.id}`}>
<td><strong>{node.name}</strong></td>
<td class="mono">{node.host}</td>
<td class="mono">{node.port}</td>
<td class="mono">{node.username}</td>
<td class="mono">{node.ffmpeg_path}</td>
<td>
<span
id={`node-status-${node.id}`}
class={`badge badge-${node.status === 'ok' ? 'done' : node.status === 'error' ? 'error' : 'pending'}`}
>
{node.status}
</span>
</td>
<td class="actions-col">
<button
data-size="sm"
hx-post={`/nodes/${node.id}/test`}
hx-target={`#node-status-${node.id}`}
hx-swap="outerHTML"
hx-indicator={`#node-spinner-${node.id}`}
>
Test
</button>
<span id={`node-spinner-${node.id}`} class="htmx-indicator muted" aria-busy="true" />
<form method="post" action={`/nodes/${node.id}/delete`} style="display:inline"
onsubmit="return confirm('Remove node?')">
<button type="submit" class="secondary" data-size="sm">Remove</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
export const NodeStatusBadge: FC<{ status: string }> = ({ status }) => (
<span class={`badge badge-${status === 'ok' ? 'done' : status === 'error' ? 'error' : 'pending'}`}>
{status}
</span>
);

371
src/views/review.tsx Normal file
View File

@@ -0,0 +1,371 @@
import type { FC } from 'hono/jsx';
import { Layout } from './layout';
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '../types';
import { streamLabel } from '../services/ffmpeg';
// ─── Language name map (ISO 639-2 → display name) ─────────────────────────────
const LANG_NAMES: Record<string, string> = {
eng: 'English', deu: 'German', spa: 'Spanish', fra: 'French', ita: 'Italian',
por: 'Portuguese', jpn: 'Japanese', kor: 'Korean', zho: 'Chinese', ara: 'Arabic',
rus: 'Russian', nld: 'Dutch', swe: 'Swedish', nor: 'Norwegian', dan: 'Danish',
fin: 'Finnish', pol: 'Polish', tur: 'Turkish', tha: 'Thai', hin: 'Hindi',
hun: 'Hungarian', ces: 'Czech', ron: 'Romanian', ell: 'Greek', heb: 'Hebrew',
fas: 'Persian', ukr: 'Ukrainian', ind: 'Indonesian', msa: 'Malay', vie: 'Vietnamese',
cat: 'Catalan', tam: 'Tamil', tel: 'Telugu', slk: 'Slovak', hrv: 'Croatian',
bul: 'Bulgarian', srp: 'Serbian', slv: 'Slovenian', lav: 'Latvian', lit: 'Lithuanian',
est: 'Estonian', isl: 'Icelandic', nob: 'Norwegian Bokmål', nno: 'Norwegian Nynorsk',
};
function langName(code: string | null): string {
if (!code) return '—';
return LANG_NAMES[code.toLowerCase()] ?? code.toUpperCase();
}
// ─── List page ────────────────────────────────────────────────────────────────
interface ReviewListProps {
items: Array<{
item: MediaItem;
plan: ReviewPlan | null;
removeCount: number;
keepCount: number;
}>;
filter: string;
totalCounts: Record<string, number>;
}
const FILTER_TABS = [
{ key: 'all', label: 'All' },
{ key: 'needs_action', label: 'Needs Action' },
{ key: 'noop', label: 'No Change' },
{ key: 'manual', label: 'Manual Review' },
{ key: 'approved', label: 'Approved' },
{ key: 'skipped', label: 'Skipped' },
{ key: 'done', label: 'Done' },
{ key: 'error', label: 'Error' },
];
export const ReviewListPage: FC<ReviewListProps> = ({ items, filter, totalCounts }) => (
<Layout title="Review" activeNav="review">
<div class="page-header">
<h1>Review</h1>
{items.some((i) => i.plan?.status === 'pending' && !i.plan.is_noop) && (
<form method="post" action="/review/approve-all" style="display:inline">
<button type="submit" data-size="sm">Approve all pending</button>
</form>
)}
</div>
<div class="filter-tabs">
{FILTER_TABS.map((tab) => (
<a
key={tab.key}
href={`/review?filter=${tab.key}`}
class={filter === tab.key ? 'active' : ''}
>
{tab.label}
{totalCounts[tab.key] != null && (
<> <span class="badge">{totalCounts[tab.key]}</span></>
)}
</a>
))}
</div>
{items.length === 0 ? (
<p class="muted">No items match this filter.</p>
) : (
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Orig. Language</th>
<th>Remove</th>
<th>Keep</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{items.map(({ item, plan, removeCount, keepCount }) => (
<ReviewRow
key={item.id}
item={item}
plan={plan}
removeCount={removeCount}
keepCount={keepCount}
/>
))}
</tbody>
</table>
)}
</Layout>
);
const ReviewRow: FC<{
item: MediaItem;
plan: ReviewPlan | null;
removeCount: number;
keepCount: number;
}> = ({ item, plan, removeCount, keepCount }) => {
const statusKey = plan?.is_noop ? 'noop' : (plan?.status ?? 'pending');
const needsManual = item.needs_review && !item.original_language;
const displayName = item.type === 'Episode' && item.series_name
? `${item.series_name} S${String(item.season_number ?? 0).padStart(2, '0')}E${String(item.episode_number ?? 0).padStart(2, '0')}${item.name}`
: item.name;
return (
<tr
id={`row-${item.id}`}
hx-get={`/review/${item.id}`}
hx-target={`#detail-${item.id}`}
hx-swap="innerHTML"
style="cursor:pointer;"
>
<td>
<div class="truncate" title={displayName}>{displayName}</div>
{item.year && <span class="muted" style="font-size:0.75rem;"> ({item.year})</span>}
</td>
<td><span class="badge">{item.type}</span></td>
<td>
{needsManual ? (
<span class="badge badge-manual">Manual</span>
) : (
<span title={item.original_language ?? ''}>{langName(item.original_language)}</span>
)}
</td>
<td>
{removeCount > 0 ? (
<span class="badge badge-remove">{removeCount} stream{removeCount !== 1 ? 's' : ''}</span>
) : (
<span class="muted"></span>
)}
</td>
<td><span class="muted">{keepCount}</span></td>
<td>
<span class={`badge badge-${statusKey}`}>
{plan?.is_noop ? 'no change' : (plan?.status ?? 'pending')}
</span>
</td>
<td class="actions-col" onclick="event.stopPropagation()">
{plan && plan.status === 'pending' && !plan.is_noop && (
<form method="post" action={`/review/${item.id}/approve`} style="display:inline">
<button type="submit" data-size="sm">Approve</button>
</form>
)}
{plan && plan.status === 'pending' && (
<form method="post" action={`/review/${item.id}/skip`} style="display:inline">
<button type="submit" class="secondary" data-size="sm">Skip</button>
</form>
)}
<a href={`/review/${item.id}`} data-size="sm" role="button" class="secondary">Detail</a>
</td>
{/* Hidden row for inline detail expansion */}
<tr id={`detail-${item.id}`} style="display:none;" />
</tr>
);
};
// ─── Detail page ──────────────────────────────────────────────────────────────
interface ReviewDetailProps {
item: MediaItem;
streams: MediaStream[];
plan: ReviewPlan | null;
decisions: StreamDecision[];
command: string | null;
}
export const ReviewDetailPage: FC<ReviewDetailProps> = ({
item,
streams,
plan,
decisions,
command,
}) => (
<Layout title={`Review — ${item.name}`} activeNav="review">
<div class="page-header">
<h1>
<a href="/review" style="font-weight:normal;margin-right:0.5rem;"> Review</a>
{item.name}
</h1>
</div>
<ReviewDetailFragment item={item} streams={streams} plan={plan} decisions={decisions} command={command} />
</Layout>
);
/** The detail fragment — also rendered as HTMX partial for inline expansion. */
export const ReviewDetailFragment: FC<ReviewDetailProps> = ({
item,
streams,
plan,
decisions,
command,
}) => {
const statusKey = plan?.is_noop ? 'noop' : (plan?.status ?? 'pending');
return (
<div class="detail-panel" id={`detail-panel-${item.id}`}>
{/* Meta */}
<dl class="detail-meta">
<div>
<dt>Type</dt>
<dd>{item.type}</dd>
</div>
{item.series_name && (
<div>
<dt>Series</dt>
<dd>{item.series_name}</dd>
</div>
)}
{item.year && (
<div>
<dt>Year</dt>
<dd>{item.year}</dd>
</div>
)}
<div>
<dt>Container</dt>
<dd class="mono">{item.container ?? '—'}</dd>
</div>
<div>
<dt>File size</dt>
<dd>{item.file_size ? formatBytes(item.file_size) : '—'}</dd>
</div>
<div>
<dt>Status</dt>
<dd><span class={`badge badge-${statusKey}`}>{statusKey}</span></dd>
</div>
</dl>
<div class="mono muted" style="font-size:0.78rem;margin-bottom:1rem;word-break:break-all;">
{item.file_path}
</div>
{/* Notes / warnings */}
{plan?.notes && (
<div class="alert alert-warning">{plan.notes}</div>
)}
{item.needs_review && !item.original_language && (
<div class="alert alert-warning">
Original language unknown audio tracks will NOT be filtered until you set it below.
</div>
)}
{/* Language override */}
<div class="flex-row" style="margin-bottom:1rem;">
<label style="margin:0;font-size:0.85rem;">Original language:</label>
<select
class="lang-select"
hx-patch={`/review/${item.id}/language`}
hx-target={`#detail-panel-${item.id}`}
hx-swap="outerHTML"
name="language"
>
<option value=""> Unknown </option>
{Object.entries(LANG_NAMES).map(([code, name]) => (
<option key={code} value={code} selected={item.original_language === code}>
{name} ({code})
</option>
))}
</select>
{item.orig_lang_source && (
<span class="badge muted">{item.orig_lang_source}</span>
)}
</div>
{/* Stream decisions table */}
<table class="stream-table">
<thead>
<tr>
<th>#</th>
<th>Type</th>
<th>Codec</th>
<th>Language</th>
<th>Title / Info</th>
<th>Flags</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{streams.map((s) => {
const dec = decisions.find((d) => d.stream_id === s.id);
const action = dec?.action ?? 'keep';
return (
<tr key={s.id} class={`stream-row-${action}`}>
<td class="mono">{s.stream_index}</td>
<td><span class="badge">{s.type}</span></td>
<td class="mono">{s.codec ?? '—'}</td>
<td>{langName(s.language)} {s.language ? <span class="muted mono">({s.language})</span> : null}</td>
<td class="truncate" title={s.title ?? ''}>
{s.title ?? (s.type === 'Audio' && s.channels ? `${s.channels}ch ${s.channel_layout ?? ''}` : '—')}
</td>
<td>
{s.is_default ? <span class="badge">default</span> : null}
{' '}{s.is_forced ? <span class="badge badge-manual">forced</span> : null}
{' '}{s.is_hearing_impaired ? <span class="badge">CC</span> : null}
</td>
<td>
{plan?.status === 'pending' && (
<form
hx-patch={`/review/${item.id}/stream/${s.id}`}
hx-target={`#detail-panel-${item.id}`}
hx-swap="outerHTML"
style="display:inline"
>
<input type="hidden" name="action" value={action === 'keep' ? 'remove' : 'keep'} />
<button
type="submit"
class={action === 'keep' ? 'toggle-keep' : 'toggle-remove'}
>
{action === 'keep' ? '✓ Keep' : '✗ Remove'}
</button>
</form>
)}
{plan?.status !== 'pending' && (
<span class={`badge badge-${action}`}>{action}</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
{/* FFmpeg command preview */}
{command && (
<div style="margin-top:1.5rem;">
<div class="muted" style="font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.35rem;">
FFmpeg command
</div>
<textarea class="command-preview" readonly rows={3}>{command}</textarea>
</div>
)}
{/* Approve / skip */}
{plan?.status === 'pending' && !plan.is_noop && (
<div class="flex-row" style="margin-top:1.5rem;">
<form method="post" action={`/review/${item.id}/approve`} style="display:inline">
<button type="submit"> Approve</button>
</form>
<form method="post" action={`/review/${item.id}/skip`} style="display:inline">
<button type="submit" class="secondary">Skip</button>
</form>
</div>
)}
{plan?.is_noop ? (
<div class="alert alert-success" style="margin-top:1rem;">
This file is already clean no changes needed.
</div>
) : null}
</div>
);
};
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
}

122
src/views/scan.tsx Normal file
View File

@@ -0,0 +1,122 @@
import type { FC } from 'hono/jsx';
import { Layout } from './layout';
interface ScanPageProps {
running: boolean;
progress: { scanned: number; total: number; errors: number };
recentItems: Array<{ name: string; type: string; scan_status: string }>;
}
export const ScanPage: FC<ScanPageProps> = ({ running, progress, recentItems }) => {
const pct = progress.total > 0 ? Math.round((progress.scanned / progress.total) * 100) : 0;
return (
<Layout title="Scan" activeNav="scan">
<div class="page-header">
<h1>Library Scan</h1>
</div>
<article id="scan-status">
<ScanStatusFragment running={running} progress={progress} />
</article>
{running && (
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
const es = new EventSource('/scan/events');
es.addEventListener('progress', function(e) {
const d = JSON.parse(e.data);
document.getElementById('progress-bar').style.width = (d.total > 0 ? Math.round(d.scanned / d.total * 100) : 0) + '%';
document.getElementById('progress-text').textContent = d.scanned + ' / ' + d.total;
document.getElementById('current-item').textContent = d.current_item ?? '';
if (d.errors > 0) document.getElementById('error-count').textContent = d.errors + ' error(s)';
});
es.addEventListener('log', function(e) {
const d = JSON.parse(e.data);
const log = document.getElementById('scan-log-body');
if (!log) return;
const tr = document.createElement('tr');
tr.innerHTML = '<td>' + d.type + '</td><td>' + escHtml(d.name) + '</td><td><span class="badge badge-' + d.status + '">' + d.status + '</span></td>';
log.prepend(tr);
while (log.children.length > 100) log.removeChild(log.lastChild);
});
es.addEventListener('complete', function() {
es.close();
location.reload();
});
es.addEventListener('error', function() {
es.close();
location.reload();
});
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
})();
`,
}}
/>
)}
<h3 style="margin-top:2rem;">Recent items</h3>
<table class="data-table">
<thead>
<tr>
<th>Type</th>
<th>Name</th>
<th>Status</th>
</tr>
</thead>
<tbody id="scan-log-body">
{recentItems.map((item, i) => (
<tr key={i}>
<td>{item.type}</td>
<td>{item.name}</td>
<td>
<span class={`badge badge-${item.scan_status}`}>{item.scan_status}</span>
</td>
</tr>
))}
</tbody>
</table>
</Layout>
);
};
export const ScanStatusFragment: FC<{
running: boolean;
progress: { scanned: number; total: number; errors: number };
}> = ({ running, progress }) => {
const pct = progress.total > 0 ? Math.round((progress.scanned / progress.total) * 100) : 0;
return (
<>
<div class="flex-row" style="margin-bottom:1rem;">
<strong>{running ? 'Scan in progress…' : 'Scan idle'}</strong>
{running ? (
<form method="post" action="/scan/stop" style="display:inline">
<button type="submit" class="secondary" data-size="sm">Stop</button>
</form>
) : (
<form method="post" action="/scan/start" style="display:inline">
<button type="submit" data-size="sm">Start Scan</button>
</form>
)}
{progress.errors > 0 && (
<span id="error-count" class="badge badge-error">{progress.errors} error(s)</span>
)}
</div>
{(running || progress.total > 0) && (
<>
<div class="progress-wrap">
<div class="progress-bar" id="progress-bar" style={`width:${pct}%`} />
</div>
<div class="flex-row muted" style="font-size:0.82rem;">
<span id="progress-text">{progress.scanned} / {progress.total}</span>
<span id="current-item" class="truncate" style="max-width:400px;" />
</div>
</>
)}
</>
);
};

223
src/views/setup.tsx Normal file
View File

@@ -0,0 +1,223 @@
import type { FC } from 'hono/jsx';
import { Layout } from './layout';
interface SetupProps {
step: 1 | 2 | 3 | 4;
config: Record<string, string>;
}
const STEPS = [
{ n: 1, label: 'Jellyfin' },
{ n: 2, label: 'Radarr' },
{ n: 3, label: 'Sonarr' },
{ n: 4, label: 'Finish' },
];
export const SetupPage: FC<SetupProps> = ({ step, config }) => (
<Layout title="Setup" activeNav="setup">
<div class="page-header">
<h1>Setup Wizard</h1>
</div>
<div class="wizard-steps">
{STEPS.map((s) => (
<div
key={s.n}
class={`wizard-step ${step === s.n ? 'active' : ''} ${step > s.n ? 'done' : ''}`}
>
{step > s.n ? '✓ ' : ''}{s.label}
</div>
))}
</div>
{step === 1 && <JellyfinStep config={config} />}
{step === 2 && <RadarrStep config={config} />}
{step === 3 && <SonarrStep config={config} />}
{step === 4 && <FinishStep config={config} />}
</Layout>
);
const JellyfinStep: FC<{ config: Record<string, string> }> = ({ config }) => (
<article>
<header><strong>Connect to Jellyfin</strong></header>
<form
hx-post="/setup/jellyfin"
hx-target="#setup-result"
hx-swap="innerHTML"
hx-indicator="#spinner"
>
<label>
Jellyfin URL
<input
type="url"
name="url"
placeholder="http://192.168.1.100:8096"
value={config.jellyfin_url ?? ''}
required
/>
</label>
<label>
API Key
<input
type="text"
name="api_key"
placeholder="your-api-key"
value={config.jellyfin_api_key ?? ''}
required
/>
<small>
Find it in Jellyfin Dashboard API Keys New API Key
</small>
</label>
<div class="flex-row">
<button type="submit">Test &amp; Save</button>
<span id="spinner" class="htmx-indicator muted" aria-busy="true" />
</div>
</form>
<div id="setup-result" />
</article>
);
const RadarrStep: FC<{ config: Record<string, string> }> = ({ config }) => (
<article>
<header><strong>Connect to Radarr (optional)</strong></header>
<p class="muted">
Radarr provides accurate original-language data for movies. Skip if not using Radarr.
</p>
<form
hx-post="/setup/radarr"
hx-target="#setup-result"
hx-swap="innerHTML"
hx-indicator="#spinner"
>
<label>
Radarr URL
<input
type="url"
name="url"
placeholder="http://192.168.1.100:7878"
value={config.radarr_url ?? ''}
/>
</label>
<label>
API Key
<input
type="text"
name="api_key"
placeholder="your-api-key"
value={config.radarr_api_key ?? ''}
/>
</label>
<div class="flex-row">
<button type="submit">Test &amp; Save</button>
<a href="/setup?step=3" role="button" class="secondary" data-size="sm">Skip</a>
<span id="spinner" class="htmx-indicator muted" aria-busy="true" />
</div>
</form>
<div id="setup-result" />
</article>
);
const SonarrStep: FC<{ config: Record<string, string> }> = ({ config }) => (
<article>
<header><strong>Connect to Sonarr (optional)</strong></header>
<p class="muted">
Sonarr provides original-language data for TV series. Skip if not using Sonarr.
</p>
<form
hx-post="/setup/sonarr"
hx-target="#setup-result"
hx-swap="innerHTML"
hx-indicator="#spinner"
>
<label>
Sonarr URL
<input
type="url"
name="url"
placeholder="http://192.168.1.100:8989"
value={config.sonarr_url ?? ''}
/>
</label>
<label>
API Key
<input
type="text"
name="api_key"
placeholder="your-api-key"
value={config.sonarr_api_key ?? ''}
/>
</label>
<div class="flex-row">
<button type="submit">Test &amp; Save</button>
<a href="/setup?step=4" role="button" class="secondary" data-size="sm">Skip</a>
<span id="spinner" class="htmx-indicator muted" aria-busy="true" />
</div>
</form>
<div id="setup-result" />
</article>
);
const FinishStep: FC<{ config: Record<string, string> }> = ({ config }) => {
const subtitleLangs: string[] = JSON.parse(config.subtitle_languages ?? '["eng","deu","spa"]');
return (
<article>
<header><strong>Language Rules</strong></header>
<p>Confirm which subtitle languages to keep in all media files.</p>
<form
hx-post="/setup/complete"
hx-push-url="/"
hx-target="body"
hx-swap="innerHTML"
>
<fieldset>
<legend>Keep subtitles in:</legend>
{[
{ code: 'eng', label: 'English' },
{ code: 'deu', label: 'German (Deutsch)' },
{ code: 'spa', label: 'Spanish (Español)' },
{ code: 'fra', label: 'French (Français)' },
{ code: 'ita', label: 'Italian (Italiano)' },
{ code: 'por', label: 'Portuguese' },
{ code: 'jpn', label: 'Japanese' },
].map(({ code, label }) => (
<label key={code}>
<input
type="checkbox"
name="subtitle_lang"
value={code}
checked={subtitleLangs.includes(code)}
/>
{label}
</label>
))}
</fieldset>
<small class="muted">
Forced subtitles and CC/SDH tracks are always kept regardless of language.
</small>
<br /><br />
<button type="submit">Complete Setup </button>
</form>
</article>
);
};
/** Partial: connection test result fragment for HTMX swap. */
export const ConnStatusFragment: FC<{ ok: boolean; error?: string; nextUrl?: string }> = ({
ok,
error,
nextUrl,
}) => (
<div>
{ok ? (
<div class="conn-status ok"> Connected successfully</div>
) : (
<div class="conn-status error"> {error ?? 'Connection failed'}</div>
)}
{ok && nextUrl && (
<a href={nextUrl} role="button" style="margin-top:1rem;display:inline-block;">
Continue
</a>
)}
</div>
);

14
tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"strict": true,
"skipLibCheck": true,
"types": ["bun-types"],
"lib": ["ESNext"]
},
"include": ["src/**/*"]
}