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:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
data/*.db
|
||||
data/*.db-shm
|
||||
data/*.db-wal
|
||||
bun.lockb
|
||||
.env
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal 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
48
bun.lock
Normal 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
11
docker-compose.yml
Normal 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
16
package.json
Normal 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
389
public/app.css
Normal 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
255
src/api/execute.tsx
Normal 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
73
src/api/nodes.tsx
Normal 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
305
src/api/review.tsx
Normal 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
341
src/api/scan.tsx
Normal 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
103
src/api/setup.tsx
Normal 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
48
src/db/index.ts
Normal 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
114
src/db/schema.ts
Normal 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
83
src/server.tsx
Normal 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
186
src/services/analyzer.ts
Normal 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
124
src/services/ffmpeg.ts
Normal 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
151
src/services/jellyfin.ts
Normal 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
108
src/services/radarr.ts
Normal 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
85
src/services/sonarr.ts
Normal 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
163
src/services/ssh.ts
Normal 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
160
src/types.ts
Normal 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
74
src/views/dashboard.tsx
Normal 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
182
src/views/execute.tsx
Normal 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
38
src/views/layout.tsx
Normal 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
130
src/views/nodes.tsx
Normal 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
371
src/views/review.tsx
Normal 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
122
src/views/scan.tsx
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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
223
src/views/setup.tsx
Normal 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 & 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 & 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 & 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
14
tsconfig.json
Normal 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/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user