From ea536ce533068e7b232719dd052c5c1205ed33d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Thu, 26 Feb 2026 22:29:33 +0100 Subject: [PATCH] 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 --- .gitignore | 6 + Dockerfile | 15 ++ bun.lock | 48 +++++ docker-compose.yml | 11 ++ package.json | 16 ++ public/app.css | 389 +++++++++++++++++++++++++++++++++++++++ src/api/execute.tsx | 255 +++++++++++++++++++++++++ src/api/nodes.tsx | 73 ++++++++ src/api/review.tsx | 305 ++++++++++++++++++++++++++++++ src/api/scan.tsx | 341 ++++++++++++++++++++++++++++++++++ src/api/setup.tsx | 103 +++++++++++ src/db/index.ts | 48 +++++ src/db/schema.ts | 114 ++++++++++++ src/server.tsx | 83 +++++++++ src/services/analyzer.ts | 186 +++++++++++++++++++ src/services/ffmpeg.ts | 124 +++++++++++++ src/services/jellyfin.ts | 151 +++++++++++++++ src/services/radarr.ts | 108 +++++++++++ src/services/sonarr.ts | 85 +++++++++ src/services/ssh.ts | 163 ++++++++++++++++ src/types.ts | 160 ++++++++++++++++ src/views/dashboard.tsx | 74 ++++++++ src/views/execute.tsx | 182 ++++++++++++++++++ src/views/layout.tsx | 38 ++++ src/views/nodes.tsx | 130 +++++++++++++ src/views/review.tsx | 371 +++++++++++++++++++++++++++++++++++++ src/views/scan.tsx | 122 ++++++++++++ src/views/setup.tsx | 223 ++++++++++++++++++++++ tsconfig.json | 14 ++ 29 files changed, 3938 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 bun.lock create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 public/app.css create mode 100644 src/api/execute.tsx create mode 100644 src/api/nodes.tsx create mode 100644 src/api/review.tsx create mode 100644 src/api/scan.tsx create mode 100644 src/api/setup.tsx create mode 100644 src/db/index.ts create mode 100644 src/db/schema.ts create mode 100644 src/server.tsx create mode 100644 src/services/analyzer.ts create mode 100644 src/services/ffmpeg.ts create mode 100644 src/services/jellyfin.ts create mode 100644 src/services/radarr.ts create mode 100644 src/services/sonarr.ts create mode 100644 src/services/ssh.ts create mode 100644 src/types.ts create mode 100644 src/views/dashboard.tsx create mode 100644 src/views/execute.tsx create mode 100644 src/views/layout.tsx create mode 100644 src/views/nodes.tsx create mode 100644 src/views/review.tsx create mode 100644 src/views/scan.tsx create mode 100644 src/views/setup.tsx create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..72ed8fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +data/*.db +data/*.db-shm +data/*.db-wal +bun.lockb +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..296ac2a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..758c010 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..14daa07 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + netfelix-audio-fix: + build: . + ports: + - "3000:3000" + volumes: + - ./data:/data + environment: + - DATA_DIR=/data + - PORT=3000 + restart: unless-stopped diff --git a/package.json b/package.json new file mode 100644 index 0000000..ba91f25 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/app.css b/public/app.css new file mode 100644 index 0000000..0115bb8 --- /dev/null +++ b/public/app.css @@ -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; +} diff --git a/src/api/execute.tsx b/src/api/execute.tsx new file mode 100644 index 0000000..80691bb --- /dev/null +++ b/src/api/execute.tsx @@ -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(); +}); + +// ─── 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((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 { + 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, 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; diff --git a/src/api/nodes.tsx b/src/api/nodes.tsx new file mode 100644 index 0000000..f0fef26 --- /dev/null +++ b/src/api/nodes.tsx @@ -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(); +}); + +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(
All fields are required.
); + } + + 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(
A node named "{name}" already exists.
); + } + throw e; + } + + const nodes = db.prepare('SELECT * FROM nodes ORDER BY name').all() as Node[]; + return c.html(); +}); + +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(); +}); + +export default app; diff --git a/src/api/review.tsx b/src/api/review.tsx new file mode 100644 index 0000000..e73244a --- /dev/null +++ b/src/api/review.tsx @@ -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): Record { + 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(); +}); + +// ─── 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( + + ); + } + + return c.html( + + ); +}); + +// ─── 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( + + ); +}); + +// ─── 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( + + ); +}); + +// ─── 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, 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, 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; diff --git a/src/api/scan.tsx b/src/api/scan.tsx new file mode 100644 index 0000000..b190a64 --- /dev/null +++ b/src/api/scan.tsx @@ -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( + + ); +}); + +// ─── 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((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 { + 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; diff --git a/src/api/setup.tsx b/src/api/setup.tsx new file mode 100644 index 0000000..47df1d8 --- /dev/null +++ b/src/api/setup.tsx @@ -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(); +}); + +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(); + } + + const result = await testJellyfin({ url, apiKey, userId: '' }); + if (!result.ok) { + return c.html(); + } + + // 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(); +}); + +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(); + } + + setConfig('radarr_url', url); + setConfig('radarr_api_key', apiKey); + setConfig('radarr_enabled', '1'); + + return c.html(); +}); + +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(); + } + + setConfig('sonarr_url', url); + setConfig('sonarr_api_key', apiKey); + setConfig('sonarr_enabled', '1'); + + return c.html(); +}); + +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; diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..7a8da30 --- /dev/null +++ b/src/db/index.ts @@ -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 { + 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 ?? ''])); +} diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..ea969a8 --- /dev/null +++ b/src/db/schema.ts @@ -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 = { + 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', +}; diff --git a/src/server.tsx b/src/server.tsx new file mode 100644 index 0000000..aa8343f --- /dev/null +++ b/src/server.tsx @@ -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( + + ); +}); + +// ─── 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, +}; diff --git a/src/services/analyzer.ts b/src/services/analyzer.ts new file mode 100644 index 0000000..b052880 --- /dev/null +++ b/src/services/analyzer.ts @@ -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 = { + 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, + 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 +): '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 = {}; + 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 = {}; + 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`; +} diff --git a/src/services/ffmpeg.ts b/src/services/ffmpeg.ts new file mode 100644 index 0000000..38af131 --- /dev/null +++ b/src/services/ffmpeg.ts @@ -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 = { 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 = { 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(' · '); +} diff --git a/src/services/jellyfin.ts b/src/services/jellyfin.ts new file mode 100644 index 0000000..8ae8821 --- /dev/null +++ b/src/services/jellyfin.ts @@ -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 { + 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): Promise { + 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; +} + +export async function* getAllItems( + cfg: JellyfinConfig, + onProgress?: (count: number, total: number) => void +): AsyncGenerator { + 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 { + 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 = { + // 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; +} diff --git a/src/services/radarr.ts b/src/services/radarr.ts new file mode 100644 index 0000000..23a7f69 --- /dev/null +++ b/src/services/radarr.ts @@ -0,0 +1,108 @@ +import { normalizeLanguage } from './jellyfin'; + +export interface RadarrConfig { + url: string; + apiKey: string; +} + +function headers(apiKey: string): Record { + 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 { + 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 = { + 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; +} diff --git a/src/services/sonarr.ts b/src/services/sonarr.ts new file mode 100644 index 0000000..dc2f030 --- /dev/null +++ b/src/services/sonarr.ts @@ -0,0 +1,85 @@ +import { normalizeLanguage } from './jellyfin'; + +export interface SonarrConfig { + url: string; + apiKey: string; +} + +function headers(apiKey: string): Record { + 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 { + 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 = { + 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; +} diff --git a/src/services/ssh.ts b/src/services/ssh.ts new file mode 100644 index 0000000..e284b71 --- /dev/null +++ b/src/services/ssh.ts @@ -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 { + // Collect lines via a promise-based queue + const queue: string[] = []; + const resolvers: Array<(value: IteratorResult) => 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>((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 { + 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[0] { + return { + host: node.host, + port: node.port, + username: node.username, + privateKey: node.private_key, + readyTimeout: 10_000, + }; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..7cf2d8e --- /dev/null +++ b/src/types.ts @@ -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; +} + +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; +} diff --git a/src/views/dashboard.tsx b/src/views/dashboard.tsx new file mode 100644 index 0000000..386d5a5 --- /dev/null +++ b/src/views/dashboard.tsx @@ -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 = ({ stats, scanRunning }) => ( + + + +
+
+
{stats.totalItems.toLocaleString()}
+
Total items
+
+
+
{stats.scanned.toLocaleString()}
+
Scanned
+
+
+
{stats.needsAction.toLocaleString()}
+
Needs action
+
+
+
{stats.noChange.toLocaleString()}
+
No change needed
+
+
+
{stats.approved.toLocaleString()}
+
Approved / queued
+
+
+
{stats.done.toLocaleString()}
+
Done
+
+ {stats.errors > 0 && ( +
+
{stats.errors.toLocaleString()}
+
Errors
+
+ )} +
+ +
+ {scanRunning ? ( + ⏳ Scan running… + ) : ( +
+ +
+ )} + Review changes + Execute jobs +
+ + {stats.scanned === 0 && ( +
+ Library not scanned yet. Click Start Scan to begin. +
+ )} +
+); diff --git a/src/views/execute.tsx b/src/views/execute.tsx new file mode 100644 index 0000000..315fe3b --- /dev/null +++ b/src/views/execute.tsx @@ -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 = ({ 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 ( + + + + {/* Stats row */} +
+ {pending} pending + {running > 0 && {running} running} + {done > 0 && {done} done} + {errors > 0 && {errors} error(s)} +
+ + {/* Controls */} +
+ {pending > 0 && ( +
+ +
+ )} + {jobs.length === 0 && ( +

No jobs yet. Go to Review and approve items first.

+ )} +
+ + {/* Job table */} + {jobs.length > 0 && ( + + + + + + + + + + + + + {jobs.map(({ job, item, node }) => ( + + ))} + +
#ItemCommandNodeStatusActions
+ )} + + {/* SSE for live updates */} + {hasActiveJobs && ( +