restructure to react spa + hono api, fix missing server/ and lib/

rewrite from monolithic hono jsx to react 19 spa with tanstack router
+ hono json api backend. add scan, review, execute, nodes, and setup
pages. multi-stage dockerfile (node for vite build, bun for runtime).

previously, server/ and src/shared/lib/ were silently excluded by
global gitignore patterns (/server/ from emacs, lib/ from python).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 22:57:40 +01:00
parent ea536ce533
commit 5ac44b7551
78 changed files with 5468 additions and 3043 deletions

41
.env.example Normal file
View File

@@ -0,0 +1,41 @@
# netfelix-audio-fix — environment configuration
# Copy this file to .env and fill in your values.
# Bun auto-loads .env on every start; .env.development/.env.test/.env.production
# are loaded additionally for the matching NODE_ENV.
#
# When JELLYFIN_URL + JELLYFIN_API_KEY are set, the setup wizard is skipped.
# JELLYFIN_USER_ID is optional — omit it when using an admin API key (uses /Items directly).
# ── Server ────────────────────────────────────────────────────────────────────
PORT=3000
DATA_DIR=./data
# ── Jellyfin ──────────────────────────────────────────────────────────────────
JELLYFIN_URL=http://jellyfin.local:8096
JELLYFIN_API_KEY=your-jellyfin-api-key
# JELLYFIN_USER_ID= # optional; omit when using an admin API key
# ── Radarr (optional) ─────────────────────────────────────────────────────────
RADARR_URL=http://radarr.local:7878
RADARR_API_KEY=your-radarr-api-key
RADARR_ENABLED=false
# ── Sonarr (optional) ─────────────────────────────────────────────────────────
SONARR_URL=http://sonarr.local:8989
SONARR_API_KEY=your-sonarr-api-key
SONARR_ENABLED=false
# ── Subtitle languages ────────────────────────────────────────────────────────
# Comma-separated ISO 639-2 codes to keep. Forced and hearing-impaired tracks
# are always kept regardless of this setting.
SUBTITLE_LANGUAGES=eng,deu,spa
# ── Media paths (optional) ────────────────────────────────────────────────────
# Host-side paths for your media libraries. Used to generate Docker commands on
# the review page. Jellyfin always exposes libraries at /movies and /series, so
# these are the left-hand sides of the Docker volume mounts:
# -v /mnt/user/storage/Movies:/movies → MOVIES_PATH=/mnt/user/storage/Movies
# -v /mnt/user/storage/Series:/series → SERIES_PATH=/mnt/user/storage/Series
#
# MOVIES_PATH=/mnt/user/storage/Movies
# SERIES_PATH=/mnt/user/storage/Series

6
.gitignore vendored
View File

@@ -4,3 +4,9 @@ data/*.db-shm
data/*.db-wal
bun.lockb
.env
.env.local
.env.development
.env.test
.env.production
dist/
src/routeTree.gen.ts

253
AGENTS.md Normal file
View File

@@ -0,0 +1,253 @@
# AGENTS.md — PWA Stack Reference
This file is the authoritative guide for AI agents (Claude Code, Cursor, Copilot, etc.) working on any app in this repository. **Always follow this stack. Never introduce dependencies outside of it without explicit approval.**
---
## Stack Overview
| Layer | Tool | Version |
| ----------------- | -------------------------------------- | ------- |
| Runtime | Bun | latest |
| Build | Vite | latest |
| UI Framework | React | 19+ |
| Component Library | shadcn/ui + Tailwind CSS v4 | latest |
| Routing | TanStack Router | latest |
| State Management | Zustand | latest |
| Local Database | PGlite (PostgreSQL in WASM) | latest |
| Forms | TanStack Form + Zod | latest |
| Animations | CSS View Transitions API | native |
| PWA | vite-plugin-pwa + Workbox | latest |
| Testing (unit) | Vitest + Testing Library | latest |
| Testing (e2e) | Playwright | latest |
| Code Quality | Biome + lint-staged + simple-git-hooks | latest |
---
## Project Structure
```
src/
├── features/ # One folder per domain feature
│ ├── auth/
│ │ ├── components/ # UI components for this feature
│ │ ├── hooks/ # Custom hooks
│ │ ├── store.ts # Zustand store (if needed)
│ │ ├── schema.ts # Zod schemas for this feature
│ │ └── index.ts # Public API of the feature
│ └── [feature-name]/
│ └── ...
├── shared/
│ ├── components/ # Generic reusable UI (Button, Modal, etc.)
│ ├── hooks/ # Generic hooks (useMediaQuery, etc.)
│ ├── db/
│ │ ├── client.ts # PGlite instance (singleton)
│ │ ├── migrations/ # SQL migration files (001_init.sql, etc.)
│ │ └── schema.ts # Zod schemas mirroring DB tables
│ └── lib/ # Pure utility functions
├── routes/ # TanStack Router route files
│ ├── __root.tsx # Root layout
│ ├── index.tsx # / route
│ └── [feature]/
│ └── index.tsx
├── app.tsx # App entry, router provider
└── main.tsx # Bun/Vite entry point
public/
├── manifest.webmanifest # PWA manifest
└── icons/ # PWA icons (all sizes)
```
---
## Rules by Layer
### React & TypeScript
- Use **React 19** features: use(), Suspense, transitions.
- All files are `.tsx` or `.ts`. No `.js` or `.jsx`.
- Prefer named exports over default exports (except route files — TanStack Router requires default exports).
- No `any`. Use `unknown` and narrow with Zod when type is uncertain.
- Use `satisfies` operator for type-safe object literals.
### Styling (Tailwind + shadcn/ui)
- **Never write custom CSS files.** Use Tailwind utility classes exclusively.
- Use `cn()` (from `src/shared/lib/utils.ts`) for conditional class merging.
- shadcn/ui components live in `src/shared/components/ui/`**never modify them directly**. Wrap them instead.
- Dark mode via Tailwind's `dark:` variant. The `class` strategy is used (not `media`).
- Responsive design: mobile-first. `sm:`, `md:`, `lg:` for larger screens.
- For "native app" feel on mobile: use `touch-action: manipulation` on interactive elements, remove tap highlight with `[-webkit-tap-highlight-color:transparent]`.
### Routing (TanStack Router)
- Use **file-based routing** via `@tanstack/router-plugin/vite`.
- Route files live in `src/routes/`. Each file exports a `Route` created with `createFileRoute`.
- **Always use `Link` and `useNavigate`** from TanStack Router. Never `<a href>` for internal navigation.
- Search params are typed via Zod — define `validateSearch` on every route that uses search params.
- Loaders (`loader` option on route) are the correct place for data fetching that should block navigation.
```ts
// Example route with typed search params
export const Route = createFileRoute("/runs/")({
validateSearch: z.object({
page: z.number().default(1),
filter: z.enum(["all", "recent"]).default("all"),
}),
component: RunsPage,
});
```
### Database (PGlite)
- The PGlite instance is a **singleton** exported from `src/shared/db/client.ts`.
- **Never import PGlite directly in components.** Always go through a hook or a feature's data layer.
- Use **numbered SQL migration files**: `src/shared/db/migrations/001_init.sql`, `002_add_segments.sql`, etc. Run them in order on app start.
- Zod schemas in `src/shared/db/schema.ts` mirror every DB table. Use them to validate data coming out of PGlite.
- For reactive UI updates from DB changes, use a thin wrapper with Zustand's `subscribeWithSelector` or re-query on relevant user actions.
```ts
// src/shared/db/client.ts
import { PGlite } from "@electric-sql/pglite";
let _db: PGlite | null = null;
export async function getDb(): Promise<PGlite> {
if (!_db) {
_db = new PGlite("idb://myapp");
await runMigrations(_db);
}
return _db;
}
```
### State Management (Zustand)
- Zustand is for **ephemeral UI state only**: modals, active tabs, current user session, UI preferences.
- **Persistent data lives in PGlite, not Zustand.** Do not use `zustand/middleware` persist for app data.
- One store file per feature: `src/features/auth/store.ts`.
- Use `subscribeWithSelector` middleware when components need to subscribe to slices.
- Keep stores flat. Avoid deeply nested state.
```ts
// Example store
import { create } from "zustand";
interface UIStore {
activeTab: string;
setActiveTab: (tab: string) => void;
}
export const useUIStore = create<UIStore>((set) => ({
activeTab: "home",
setActiveTab: (tab) => set({ activeTab: tab }),
}));
```
### Forms (TanStack Form + Zod)
- All forms use `useForm` from `@tanstack/react-form`.
- Validation is always done with a Zod schema via the `validators` option.
- **Reuse Zod schemas** from `src/shared/db/schema.ts` or `src/features/[x]/schema.ts` — do not duplicate validation logic.
- Error messages are shown inline below the field, never in a toast.
```ts
const form = useForm({
defaultValues: { name: "" },
validators: { onChange: myZodSchema },
onSubmit: async ({ value }) => {
/* ... */
},
});
```
### Animations (CSS View Transitions API)
- Use the native **View Transitions API** for page transitions. No animation libraries.
- Wrap navigation calls with `document.startViewTransition()`.
- TanStack Router's `RouterDevtools` and upcoming native support handle this — check the TanStack Router docs for the current recommended integration.
- Use `view-transition-name` CSS property on elements that should animate between routes.
- Provide a `@media (prefers-reduced-motion: reduce)` fallback that disables transitions.
```css
/* Disable transitions for users who prefer it */
@media (prefers-reduced-motion: reduce) {
::view-transition-old(*),
::view-transition-new(*) {
animation: none;
}
}
```
### PWA (vite-plugin-pwa)
- Config lives in `vite.config.ts` under the `VitePWA()` plugin.
- Strategy: `generateSW` (not `injectManifest`) unless custom SW logic is needed.
- **Always precache** the PGlite WASM files — without this the app won't work offline.
- `manifest.webmanifest` must include: `name`, `short_name`, `start_url: "/"`, `display: "standalone"`, `theme_color`, icons at 192×192 and 512×512.
- Register the SW with `registerType: 'autoUpdate'` and show a "New version available" toast.
### Testing
**Unit/Component Tests (Vitest)**
- Test files co-located with source: `src/features/auth/auth.test.ts`.
- Use Testing Library for component tests. Query by role, label, or text — never by class or id.
- Mock PGlite with an in-memory instance (no `idb://` prefix) in tests.
- Run with: `bun test`
**E2E Tests (Playwright)**
- Test files in `e2e/`.
- Focus on critical user flows: onboarding, data entry, offline behavior, install prompt.
- Use `page.evaluate()` to inspect IndexedDB/PGlite state when needed.
- Run with: `bun e2e`
### Code Quality (Biome)
- Biome handles both **formatting and linting** — no Prettier, no ESLint.
- Config in `biome.json` at project root.
- `lint-staged` + `simple-git-hooks` run Biome on staged files before every commit.
- CI also runs `biome check --apply` — commits that fail Biome are rejected.
- **Never disable Biome rules with inline comments** without a code comment explaining why.
---
## Commands
```bash
bun install # Install dependencies
bun dev # Start dev server
bun build # Production build
bun preview # Preview production build locally
bun test # Run Vitest unit tests
bun e2e # Run Playwright E2E tests
bun lint # Run Biome linter
bun format # Run Biome formatter
```
---
## Do Not
- ❌ Do not use `npm` or `yarn` — always use `bun`
- ❌ Do not add ESLint, Prettier, or Husky — Biome + simple-git-hooks covers this
- ❌ Do not use `react-query` or `swr` — data comes from PGlite, not a remote API
- ❌ Do not store persistent app data in Zustand — use PGlite
- ❌ Do not use Framer Motion / React Spring — use CSS View Transitions
- ❌ Do not use `<a href>` for internal links — use TanStack Router's `<Link>`
- ❌ Do not write raw CSS files — use Tailwind utilities
- ❌ Do not modify files in `src/shared/components/ui/` — wrap shadcn components instead
- ❌ Do not introduce a new dependency without checking if the existing stack already covers the need
---
## Adding a New Feature Checklist
1. Create `src/features/[name]/` folder
2. Add Zod schema in `src/features/[name]/schema.ts`
3. Add DB migration in `src/shared/db/migrations/` if needed
4. Add route file in `src/routes/[name]/index.tsx`
5. Add Zustand store in `src/features/[name]/store.ts` if UI state is needed
6. Write unit tests co-located with the feature
7. Write at least one Playwright E2E test for the happy path
8. Run `bun lint` and `bun test` before committing

View File

@@ -1,15 +1,18 @@
FROM oven/bun:1 AS base
FROM node:22-slim AS build
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
COPY package.json ./
RUN npm install
COPY . .
RUN npx vite build
FROM oven/bun:1
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile --production
COPY --from=build /app/dist ./dist
COPY --from=build /app/server ./server
EXPOSE 3000
ENV DATA_DIR=/data
ENV PORT=3000
VOLUME ["/data"]
CMD ["bun", "run", "src/server.tsx"]
CMD ["bun", "run", "server/index.tsx"]

22
biome.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"organizeImports": { "enabled": true },
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 1,
"lineWidth": 120
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": { "noExplicitAny": "off" },
"style": { "noNonNullAssertion": "off" }
}
},
"files": {
"ignore": ["node_modules", "dist", "src/routeTree.gen.ts"]
}
}

533
bun.lock
View File

@@ -5,44 +5,577 @@
"": {
"name": "netfelix-audio-fix",
"dependencies": {
"@tanstack/react-form": "^1.28.3",
"@tanstack/react-router": "^1.163.3",
"clsx": "^2.1.1",
"hono": "^4",
"react": "19",
"react-dom": "19",
"ssh2": "^1",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6",
"zustand": "^5.0.11",
},
"devDependencies": {
"@biomejs/biome": "^2.4.4",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/router-plugin": "^1.163.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/ssh2": "^1",
"@vitejs/plugin-react-swc": "^4.2.3",
"bun-types": "latest",
"concurrently": "^9.2.1",
"tailwindcss": "^4.2.1",
"vite": "^7.3.1",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
"@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="],
"@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@biomejs/biome": ["@biomejs/biome@2.4.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.4", "@biomejs/cli-darwin-x64": "2.4.4", "@biomejs/cli-linux-arm64": "2.4.4", "@biomejs/cli-linux-arm64-musl": "2.4.4", "@biomejs/cli-linux-x64": "2.4.4", "@biomejs/cli-linux-x64-musl": "2.4.4", "@biomejs/cli-win32-arm64": "2.4.4", "@biomejs/cli-win32-x64": "2.4.4" }, "bin": { "biome": "bin/biome" } }, "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
"@swc/core": ["@swc/core@1.15.17", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.17", "@swc/core-darwin-x64": "1.15.17", "@swc/core-linux-arm-gnueabihf": "1.15.17", "@swc/core-linux-arm64-gnu": "1.15.17", "@swc/core-linux-arm64-musl": "1.15.17", "@swc/core-linux-x64-gnu": "1.15.17", "@swc/core-linux-x64-musl": "1.15.17", "@swc/core-win32-arm64-msvc": "1.15.17", "@swc/core-win32-ia32-msvc": "1.15.17", "@swc/core-win32-x64-msvc": "1.15.17" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-Mu3eOrYlkdQPl7yqotNckitTr6FZ0yd7mlWIBEzK+EGIyybgMENJHmbS2DeA7BMleJiBElP6ke+Nz93pkKmKJw=="],
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eB9qdyt4E60323IS0rgV/rd79DJ+YWSyIKi+sT1dlIgR3ns4xlBiunREM3lVH0FKcUbhttiBvdVubT4QoOuZ+w=="],
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1TZARYs8947jJpSioqcPrusz+wEeABF4iiSdwcSyQh2rIUdIEk5FOyaqJASFPJ6dZfx7ZVOyjtDATVAegs2/Q=="],
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.17", "", { "os": "linux", "cpu": "arm" }, "sha512-p6282NQZo5bzx0wphz1ETGjhcRB9CN+/XUAjQwApyoyX9iCloI5IT/RC3vjbflo42g8RPTxUTaItAO0hlLSesQ=="],
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-TGnDS4ejy8y9jqxXqZCyA+DvFc64nXUHS9rxdyeJ9B9uyIdtKVhBrA2xfghYRS/sSPSyHZ0yu89NxBICvONH+A=="],
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-D0/6Hj4CkgSTTahtlGxv9IDsLTuvQz30mkZEMDp8TqwYhCL8AomznkibwlQU8HtY4q/dqd1OGRPH+FmNb4BBEA=="],
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.17", "", { "os": "linux", "cpu": "x64" }, "sha512-1s2OFsg6DeRkWU7c+PIfIHZsFCbiZ34akXFHrg7KjpF8zIvpHZNoUUZimoWEwcB6GquXSkAO+1b5KpG5nusTeQ=="],
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.17", "", { "os": "linux", "cpu": "x64" }, "sha512-gtxGMGYtRWWmCcgx6xM2Yos43uiE/j8kZwkeL/LNGG9zM0tatd23NsfL9PnQJ45hY7QZ+dx2rM68e4ArgG4kJg=="],
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-gxi+/Miytez/O9vJ/QiheIivA3oWZjPp9nJu3VmAfLMWUzcZORMwgaI1ygtDTLjz7CzcwlGMJz/Ab66Y5DfNpg=="],
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.17", "", { "os": "win32", "cpu": "ia32" }, "sha512-KUsRqNbTp7SpNK0T9m4+i8GlngzNjwb69a3ttKA6XJ5r6Pewm+NSYji93pNkawXIivbWY2jhvceGMAyd+4hWaQ=="],
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.17", "", { "os": "win32", "cpu": "x64" }, "sha512-zqtEGE0/rTKvEC5sOtpANLHeWEPjsTD4/rwpUxo6ymztcLI/Z+L9Wi9xQvIGmLTUih1gvNZcAwROqdfRP3oAWQ=="],
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
"@swc/types": ["@swc/types@0.1.25", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="],
"@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.0", "", {}, "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw=="],
"@tanstack/form-core": ["@tanstack/form-core@1.28.3", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.8.1" } }, "sha512-DBhnu1d5VfACAYOAZJO8tsEUHjWczZMJY8v/YrtAJNWpwvL/3ogDuz8e6yUB2m/iVTNq6K8yrnVN2nrX0/BX/w=="],
"@tanstack/history": ["@tanstack/history@1.161.4", "", {}, "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww=="],
"@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="],
"@tanstack/react-form": ["@tanstack/react-form@1.28.3", "", { "dependencies": { "@tanstack/form-core": "1.28.3", "@tanstack/react-store": "^0.8.1" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-84yd0swZRcyC3Q46dYBH6bHf1tlIY1flchbdG3VwArg/wLVW5RdBenIrJhleHjk2OxXuF+9HoKQbHglJyWIXQA=="],
"@tanstack/react-router": ["@tanstack/react-router@1.163.3", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.163.3", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-hheBbFVb+PbxtrWp8iy6+TTRTbhx3Pn6hKo8Tv/sWlG89ZMcD1xpQWzx8ukHN9K8YWbh5rdzt4kv6u8X4kB28Q=="],
"@tanstack/react-store": ["@tanstack/react-store@0.8.1", "", { "dependencies": { "@tanstack/store": "0.8.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig=="],
"@tanstack/router-core": ["@tanstack/router-core@1.163.3", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA=="],
"@tanstack/router-generator": ["@tanstack/router-generator@1.163.3", "", { "dependencies": { "@tanstack/router-core": "1.163.3", "@tanstack/router-utils": "1.161.4", "@tanstack/virtual-file-routes": "1.161.4", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-i2rWRtqY/yCYUDXva1li4zeDP20oFjMt/wh9RnGJCrKSLWrvEGnxAOSyXgiOsoJnU96TTQ0mUDbGfXsSTupeZQ=="],
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.163.3", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.163.3", "@tanstack/router-generator": "1.163.3", "@tanstack/router-utils": "1.161.4", "@tanstack/virtual-file-routes": "1.161.4", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.163.3", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-JOUYuUX2N9ZHnmkmvmiGzXGbkvrur/5BfW/+vpiZzuifSyvdc0XsfwkTpjvwWx9ymp4ZshSVKiQQKQi09YweIw=="],
"@tanstack/router-utils": ["@tanstack/router-utils@1.161.4", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-r8TpjyIZoqrXXaf2DDyjd44gjGBoyE+/oEaaH68yLI9ySPO1gUWmQENZ1MZnmBnpUGN24NOZxdjDLc8npK0SAw=="],
"@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="],
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.4", "", {}, "sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
"@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@4.2.3", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.2", "@swc/core": "^1.15.11" }, "peerDependencies": { "vite": "^4 || ^5 || ^6 || ^7" } }, "sha512-QIluDil2prhY1gdA3GGwxZzTAmLdi8cQ2CcuMW4PB/Wu4e/1pzqrwhYWVd09LInCRlDUidQjd0B70QWbjWtLxA=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"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=="],
"caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
"cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.302", "", {}, "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hono": ["hono@4.12.3", "", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"isbot": ["isbot@5.1.35", "", {}, "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nan": ["nan@2.25.0", "", {}, "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
"seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="],
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"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=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tanstack/form-core/@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="],
"@tanstack/react-router/@tanstack/react-store": ["@tanstack/react-store@0.9.1", "", { "dependencies": { "@tanstack/store": "0.9.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA=="],
"@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="],
"@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"bun-types/@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="],
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>netfelix-audio-fix</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -2,15 +2,38 @@
"name": "netfelix-audio-fix",
"version": "2026.02.26",
"scripts": {
"dev": "bun --hot src/server.tsx",
"start": "bun src/server.tsx"
"dev:server": "NODE_ENV=development bun --hot server/index.tsx",
"dev:client": "vite",
"dev": "concurrently \"bun run dev:server\" \"bun run dev:client\"",
"build": "vite build",
"start": "bun server/index.tsx",
"lint": "biome check .",
"format": "biome format . --write",
"test": "echo 'No tests yet'"
},
"dependencies": {
"@tanstack/react-form": "^1.28.3",
"@tanstack/react-router": "^1.163.3",
"clsx": "^2.1.1",
"hono": "^4",
"ssh2": "^1"
"react": "19",
"react-dom": "19",
"ssh2": "^1",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
"devDependencies": {
"@biomejs/biome": "^2.4.4",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/router-plugin": "^1.163.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/ssh2": "^1",
"bun-types": "latest"
"@vitejs/plugin-react-swc": "^4.2.3",
"bun-types": "latest",
"concurrently": "^9.2.1",
"tailwindcss": "^4.2.1",
"vite": "^7.3.1"
}
}

View File

@@ -1,389 +0,0 @@
/* ─── 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;
}

22
server/api/dashboard.ts Normal file
View File

@@ -0,0 +1,22 @@
import { Hono } from 'hono';
import { getDb, getConfig } from '../db/index';
const app = new Hono();
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';
const setupComplete = getConfig('setup_complete') === '1';
return c.json({ stats: { totalItems, scanned, needsAction, approved, done, errors, noChange }, scanRunning, setupComplete });
});
export default app;

228
server/api/execute.ts Normal file
View File

@@ -0,0 +1,228 @@
import { Hono } from 'hono';
import { stream } from 'hono/streaming';
import { getDb } from '../db/index';
import { execStream } from '../services/ssh';
import type { Job, Node, MediaItem, MediaStream } from '../types';
import { predictExtractedFiles } from '../services/ffmpeg';
import { accessSync, constants } from 'node:fs';
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);
}
function loadJobRow(jobId: number) {
const db = getDb();
const row = db.prepare(`
SELECT j.*, mi.id as mi_id, mi.name, mi.type, mi.series_name, mi.season_number,
mi.episode_number, mi.file_path,
n.id as n_id, 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
WHERE j.id = ?
`).get(jobId) as (Job & {
mi_id: number | null; name: string | null; type: string | null;
series_name: string | null; season_number: number | null; episode_number: number | null;
file_path: string | null; n_id: number | null; 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;
}) | undefined;
if (!row) return null;
const nodes = db.prepare('SELECT * FROM nodes ORDER BY name').all() as Node[];
const item = row.name ? { id: row.item_id, name: row.name, type: row.type, series_name: row.series_name, season_number: row.season_number, episode_number: row.episode_number, file_path: row.file_path } as unknown as MediaItem : null;
const node = row.node_name ? { id: row.node_id!, name: row.node_name, host: row.host!, port: row.port!, username: row.username!, private_key: row.private_key!, ffmpeg_path: row.ffmpeg_path!, work_dir: row.work_dir!, status: row.node_status! } as unknown as Node : null;
return { job: row as unknown as Job, item, node, nodes };
}
// ─── List ─────────────────────────────────────────────────────────────────────
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.json({ jobs, 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.json({ ok: true, started: pending.length });
});
// ─── Assign node ──────────────────────────────────────────────────────────────
app.post('/job/:id/assign', async (c) => {
const db = getDb();
const jobId = Number(c.req.param('id'));
const body = await c.req.json<{ node_id: number | null }>();
db.prepare('UPDATE jobs SET node_id = ? WHERE id = ?').run(body.node_id, jobId);
const result = loadJobRow(jobId);
if (!result) return c.notFound();
return c.json(result);
});
// ─── Run single ───────────────────────────────────────────────────────────────
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') {
const result = loadJobRow(jobId);
if (!result) return c.notFound();
return c.json(result);
}
runJob(job).catch((err) => console.error(`Job ${job.id} failed:`, err));
const result = loadJobRow(jobId);
if (!result) return c.notFound();
return c.json(result);
});
// ─── Cancel ───────────────────────────────────────────────────────────────────
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.json({ ok: true });
});
// ─── 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();
if (!job.node_id) {
const itemRow = db.prepare('SELECT file_path FROM media_items WHERE id = ?').get(job.item_id) as { file_path: string } | undefined;
if (itemRow?.file_path) {
try { accessSync(itemRow.file_path, constants.R_OK | constants.W_OK); } catch (fsErr) {
const msg = `File not accessible: ${itemRow.file_path}\n${(fsErr as Error).message}`;
db.prepare("UPDATE jobs SET status = 'error', output = ?, exit_code = 1, completed_at = datetime('now') WHERE id = ?").run(msg, job.id);
emitJobUpdate(job.id, 'error', msg);
db.prepare("UPDATE review_plans SET status = 'error' WHERE item_id = ?").run(job.item_id);
return;
}
}
}
db.prepare("UPDATE jobs SET status = 'running', started_at = datetime('now'), output = '' WHERE id = ?").run(job.id);
emitJobUpdate(job.id, 'running');
let outputLines: string[] = [];
const flush = (final = false) => {
const text = outputLines.join('\n');
if (final || outputLines.length % 10 === 0) db.prepare('UPDATE jobs SET output = ? WHERE id = ?').run(text, job.id);
emitJobUpdate(job.id, 'running', text);
};
try {
if (job.node_id) {
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(); }
} else {
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);
flush();
}
} 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);
db.prepare("UPDATE review_plans SET status = 'done' WHERE item_id = ?").run(job.item_id);
// Populate subtitle_files table with extracted sidecar files
try {
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(job.item_id) as MediaItem | undefined;
const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ?').all(job.item_id) as MediaStream[];
if (item && streams.length > 0) {
const files = predictExtractedFiles(item, streams);
const insertFile = db.prepare('INSERT OR IGNORE INTO subtitle_files (item_id, file_path, language, codec, is_forced, is_hearing_impaired) VALUES (?, ?, ?, ?, ?, ?)');
for (const f of files) {
insertFile.run(job.item_id, f.file_path, f.language, f.codec, f.is_forced ? 1 : 0, f.is_hearing_impaired ? 1 : 0);
}
db.prepare('UPDATE review_plans SET subs_extracted = 1 WHERE item_id = ?').run(job.item_id);
}
} catch (subErr) { console.error('Failed to record extracted subtitle files:', subErr); }
} 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;

74
server/api/nodes.ts Normal file
View File

@@ -0,0 +1,74 @@
import { Hono } from 'hono';
import { getDb } from '../db/index';
import { testConnection } from '../services/ssh';
import type { Node } from '../types';
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.json({ nodes });
});
app.post('/', async (c) => {
const db = getDb();
const contentType = c.req.header('Content-Type') ?? '';
let name: string, host: string, port: number, username: string, ffmpegPath: string, workDir: string, privateKey: string;
// Support both multipart (file upload) and JSON
if (contentType.includes('multipart/form-data')) {
const body = await c.req.formData();
name = body.get('name') as string;
host = body.get('host') as string;
port = Number(body.get('port') ?? '22');
username = body.get('username') as string;
ffmpegPath = (body.get('ffmpeg_path') as string) || 'ffmpeg';
workDir = (body.get('work_dir') as string) || '/tmp';
const keyFile = body.get('private_key') as File | null;
if (!name || !host || !username || !keyFile) return c.json({ ok: false, error: 'All fields are required' }, 400);
privateKey = await keyFile.text();
} else {
const body = await c.req.json<{ name: string; host: string; port?: number; username: string; ffmpeg_path?: string; work_dir?: string; private_key: string }>();
name = body.name; host = body.host; port = body.port ?? 22; username = body.username;
ffmpegPath = body.ffmpeg_path || 'ffmpeg'; workDir = body.work_dir || '/tmp'; privateKey = body.private_key;
if (!name || !host || !username || !privateKey) return c.json({ ok: false, error: 'All fields are required' }, 400);
}
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.json({ ok: false, error: `A node named "${name}" already exists` }, 409);
throw e;
}
const nodes = db.prepare('SELECT * FROM nodes ORDER BY name').all() as Node[];
return c.json({ ok: true, nodes });
});
app.delete('/:id', (c) => {
const db = getDb();
db.prepare('DELETE FROM nodes WHERE id = ?').run(Number(c.req.param('id')));
return c.json({ ok: true });
});
// Legacy POST delete for HTML-form compat (may be removed later)
app.post('/:id/delete', (c) => {
const db = getDb();
db.prepare('DELETE FROM nodes WHERE id = ?').run(Number(c.req.param('id')));
return c.json({ ok: true });
});
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: ${result.error}`;
db.prepare("UPDATE nodes SET status = ?, last_checked_at = datetime('now') WHERE id = ?").run(status, id);
return c.json({ ok: result.ok, status, error: result.error });
});
export default app;

372
server/api/review.ts Normal file
View File

@@ -0,0 +1,372 @@
import { Hono } from 'hono';
import { getDb, getConfig, getAllConfig } from '../db/index';
import { analyzeItem } from '../services/analyzer';
import { buildCommand, buildDockerCommand } from '../services/ffmpeg';
import { normalizeLanguage, getItem, refreshItem, mapStream } from '../services/jellyfin';
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '../types';
const app = new Hono();
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getSubtitleLanguages(): string[] {
return JSON.parse(getConfig('subtitle_languages') ?? '["eng","deu","spa"]');
}
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 };
}
function buildWhereClause(filter: string): string {
switch (filter) {
case 'needs_action': return "rp.status = 'pending' AND rp.is_noop = 0";
case 'noop': return 'rp.is_noop = 1';
case 'manual': return 'mi.needs_review = 1 AND mi.original_language IS NULL';
case 'approved': return "rp.status = 'approved'";
case 'skipped': return "rp.status = 'skipped'";
case 'done': return "rp.status = 'done'";
case 'error': return "rp.status = 'error'";
default: return '1=1';
}
}
type RawRow = 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;
};
function rowToPlan(r: RawRow): ReviewPlan | null {
if (r.plan_id == null) return null;
return { 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;
}
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, dockerCommand: null, dockerMountDir: 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 ? buildCommand(item, streams, decisions) : null;
const cfg = getAllConfig();
let dockerCommand: string | null = null;
let dockerMountDir: string | null = null;
if (plan && !plan.is_noop) {
const result = buildDockerCommand(item, streams, decisions, { moviesPath: cfg.movies_path || undefined, seriesPath: cfg.series_path || undefined });
dockerCommand = result.command;
dockerMountDir = result.mountDir;
}
return { item, streams, plan: plan ?? null, decisions, command, dockerCommand, dockerMountDir };
}
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 });
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 };
const existingTitles = new Map<number, string | null>(
(db.prepare('SELECT stream_id, custom_title FROM stream_decisions WHERE plan_id = ?').all(plan.id) as { stream_id: number; custom_title: string | null }[])
.map((r) => [r.stream_id, r.custom_title])
);
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, custom_title) VALUES (?, ?, ?, ?, ?)')
.run(plan.id, dec.stream_id, dec.action, dec.target_index, existingTitles.get(dec.stream_id) ?? null);
}
}
// ─── List ─────────────────────────────────────────────────────────────────────
app.get('/', (c) => {
const db = getDb();
const filter = c.req.query('filter') ?? 'all';
const where = buildWhereClause(filter);
const movieRows = 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 mi.type = 'Movie' AND ${where}
GROUP BY mi.id ORDER BY mi.name LIMIT 500
`).all() as RawRow[];
const movies = movieRows.map((r) => ({ item: r as unknown as MediaItem, plan: rowToPlan(r), removeCount: r.remove_count, keepCount: r.keep_count }));
const series = db.prepare(`
SELECT COALESCE(mi.series_jellyfin_id, mi.series_name) as series_key, mi.series_name,
MAX(mi.original_language) as original_language,
COUNT(DISTINCT mi.season_number) as season_count, COUNT(mi.id) as episode_count,
SUM(CASE WHEN rp.is_noop = 1 THEN 1 ELSE 0 END) as noop_count,
SUM(CASE WHEN rp.status = 'pending' AND rp.is_noop = 0 THEN 1 ELSE 0 END) as needs_action_count,
SUM(CASE WHEN rp.status = 'approved' THEN 1 ELSE 0 END) as approved_count,
SUM(CASE WHEN rp.status = 'skipped' THEN 1 ELSE 0 END) as skipped_count,
SUM(CASE WHEN rp.status = 'done' THEN 1 ELSE 0 END) as done_count,
SUM(CASE WHEN rp.status = 'error' THEN 1 ELSE 0 END) as error_count,
SUM(CASE WHEN mi.needs_review = 1 AND mi.original_language IS NULL THEN 1 ELSE 0 END) as manual_count
FROM media_items mi
LEFT JOIN review_plans rp ON rp.item_id = mi.id
WHERE mi.type = 'Episode' AND ${where}
GROUP BY series_key ORDER BY mi.series_name
`).all();
const totalCounts = countsByFilter(db);
return c.json({ movies, series, filter, totalCounts });
});
// ─── Series episodes ──────────────────────────────────────────────────────────
app.get('/series/:seriesKey/episodes', (c) => {
const db = getDb();
const seriesKey = decodeURIComponent(c.req.param('seriesKey'));
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, 0 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 mi.type = 'Episode'
AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?))
GROUP BY mi.id ORDER BY mi.season_number, mi.episode_number
`).all(seriesKey, seriesKey) as RawRow[];
const seasonMap = new Map<number | null, unknown[]>();
for (const r of rows) {
const season = (r as unknown as { season_number: number | null }).season_number ?? null;
if (!seasonMap.has(season)) seasonMap.set(season, []);
seasonMap.get(season)!.push({ item: r as unknown as MediaItem, plan: rowToPlan(r), removeCount: r.remove_count });
}
const seasons = Array.from(seasonMap.entries())
.sort(([a], [b]) => (a ?? -1) - (b ?? -1))
.map(([season, episodes]) => ({
season,
episodes,
noopCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.is_noop).length,
actionCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.status === 'pending' && !e.plan.is_noop).length,
approvedCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.status === 'approved').length,
doneCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.status === 'done').length,
}));
return c.json({ seasons });
});
// ─── Approve series ───────────────────────────────────────────────────────────
app.post('/series/:seriesKey/approve-all', (c) => {
const db = getDb();
const seriesKey = decodeURIComponent(c.req.param('seriesKey'));
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 mi.type = 'Episode' AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?))
AND rp.status = 'pending' AND rp.is_noop = 0
`).all(seriesKey, seriesKey) 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) db.prepare("INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')").run(plan.item_id, buildCommand(item, streams, decisions));
}
return c.json({ ok: true, count: pending.length });
});
// ─── Approve season ───────────────────────────────────────────────────────────
app.post('/season/:seriesKey/:season/approve-all', (c) => {
const db = getDb();
const seriesKey = decodeURIComponent(c.req.param('seriesKey'));
const season = Number(c.req.param('season'));
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 mi.type = 'Episode' AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?))
AND mi.season_number = ? AND rp.status = 'pending' AND rp.is_noop = 0
`).all(seriesKey, seriesKey, season) 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) db.prepare("INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')").run(plan.item_id, buildCommand(item, streams, decisions));
}
return c.json({ ok: true, count: pending.length });
});
// ─── 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) db.prepare("INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')").run(plan.item_id, buildCommand(item, streams, decisions));
}
return c.json({ ok: true, count: pending.length });
});
// ─── Detail ───────────────────────────────────────────────────────────────────
app.get('/:id', (c) => {
const db = getDb();
const id = Number(c.req.param('id'));
const detail = loadItemDetail(db, id);
if (!detail.item) return c.notFound();
return c.json(detail);
});
// ─── Override language ────────────────────────────────────────────────────────
app.patch('/:id/language', async (c) => {
const db = getDb();
const id = Number(c.req.param('id'));
const body = await c.req.json<{ language: string | null }>();
const lang = body.language || null;
db.prepare("UPDATE media_items SET original_language = ?, orig_lang_source = 'manual', needs_review = 0 WHERE id = ?")
.run(lang ? normalizeLanguage(lang) : null, id);
reanalyze(db, id);
const detail = loadItemDetail(db, id);
if (!detail.item) return c.notFound();
return c.json(detail);
});
// ─── Edit stream title ────────────────────────────────────────────────────────
app.patch('/:id/stream/:streamId/title', async (c) => {
const db = getDb();
const itemId = Number(c.req.param('id'));
const streamId = Number(c.req.param('streamId'));
const body = await c.req.json<{ title: string }>();
const title = (body.title ?? '').trim() || null;
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 custom_title = ? WHERE plan_id = ? AND stream_id = ?').run(title, plan.id, streamId);
const detail = loadItemDetail(db, itemId);
if (!detail.item) return c.notFound();
return c.json(detail);
});
// ─── 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.json<{ action: 'keep' | 'remove' }>();
const action = body.action;
// Only audio streams can be toggled — subtitles are always removed (extracted to sidecar)
const stream = db.prepare('SELECT type FROM media_streams WHERE id = ?').get(streamId) as { type: string } | undefined;
if (stream?.type === 'Subtitle') return c.json({ error: 'Subtitle streams cannot be toggled' }, 400);
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);
// is_noop only considers audio streams (subtitle removal is implicit)
const audioNotKept = (db.prepare(`
SELECT COUNT(*) as n FROM stream_decisions sd
JOIN media_streams ms ON ms.id = sd.stream_id
WHERE sd.plan_id = ? AND ms.type = 'Audio' AND sd.action != 'keep'
`).get(plan.id) as { n: number }).n;
// Also check audio ordering
const isNoop = audioNotKept === 0; // simplified — full recheck would need analyzer
db.prepare('UPDATE review_plans SET is_noop = ? WHERE id = ?').run(isNoop ? 1 : 0, plan.id);
const detail = loadItemDetail(db, itemId);
if (!detail.item) return c.notFound();
return c.json(detail);
});
// ─── 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);
if (!plan.is_noop) {
const { item, streams, decisions } = loadItemDetail(db, id);
if (item) db.prepare("INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')").run(id, buildCommand(item, streams, decisions));
}
return c.json({ ok: true });
});
// ─── Skip / Unskip ───────────────────────────────────────────────────────────
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.json({ ok: true });
});
app.post('/:id/unskip', (c) => {
const db = getDb();
const id = Number(c.req.param('id'));
db.prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE item_id = ? AND status = 'skipped'").run(id);
return c.json({ ok: true });
});
// ─── Rescan ───────────────────────────────────────────────────────────────────
app.post('/:id/rescan', async (c) => {
const db = getDb();
const id = Number(c.req.param('id'));
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(id) as MediaItem | undefined;
if (!item) return c.notFound();
const cfg = getAllConfig();
const jfCfg = { url: cfg.jellyfin_url, apiKey: cfg.jellyfin_api_key, userId: cfg.jellyfin_user_id };
// Trigger Jellyfin's internal metadata probe and wait for it to finish
// so the streams we fetch afterwards reflect the current file on disk.
await refreshItem(jfCfg, item.jellyfin_id);
const fresh = await getItem(jfCfg, item.jellyfin_id);
if (fresh) {
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
db.prepare('DELETE FROM media_streams WHERE item_id = ?').run(id);
for (const jStream of fresh.MediaStreams ?? []) {
if (jStream.IsExternal) continue; // skip external subs — not embedded in container
const s = mapStream(jStream);
insertStream.run(id, 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);
}
}
reanalyze(db, id);
const detail = loadItemDetail(db, id);
if (!detail.item) return c.notFound();
return c.json(detail);
});
export default app;

View File

@@ -1,18 +1,15 @@
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 { getAllItems, getDevItems, 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 ───────────────────────────────────────────
// ─── State ────────────────────────────────────────────────────────────────────
let scanAbort: AbortController | null = null;
const scanListeners = new Set<(data: string) => void>();
@@ -22,7 +19,12 @@ function emitSse(type: string, data: unknown): void {
for (const listener of scanListeners) listener(line);
}
// ─── Pages ────────────────────────────────────────────────────────────────────
function currentScanLimit(): number | null {
const v = getConfig('scan_limit');
return v ? Number(v) : null;
}
// ─── Status ───────────────────────────────────────────────────────────────────
app.get('/', (c) => {
const db = getDb();
@@ -31,43 +33,44 @@ app.get('/', (c) => {
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"
'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}
/>
);
return c.json({ running, progress: { scanned, total, errors }, recentItems, scanLimit: currentScanLimit() });
});
// ─── Start scan ───────────────────────────────────────────────────────────────
// ─── Start ────────────────────────────────────────────────────────────────────
app.post('/start', async (c) => {
if (getConfig('scan_running') === '1') {
return c.redirect('/scan');
return c.json({ ok: false, error: 'Scan already running' }, 409);
}
const body = await c.req.json<{ limit?: number }>().catch(() => ({}));
const formLimit = body.limit ?? null;
const envLimit = process.env.SCAN_LIMIT ? Number(process.env.SCAN_LIMIT) : null;
const limit = formLimit ?? envLimit ?? null;
setConfig('scan_limit', limit != null ? String(limit) : '');
setConfig('scan_running', '1');
// Start scan in background (fire and forget)
runScan().catch((err) => {
runScan(limit).catch((err) => {
console.error('Scan error:', err);
setConfig('scan_running', '0');
emitSse('error', { message: String(err) });
});
return c.redirect('/scan');
return c.json({ ok: true });
});
// ─── Stop scan ────────────────────────────────────────────────────────────────
// ─── Stop ─────────────────────────────────────────────────────────────────────
app.post('/stop', (c) => {
scanAbort?.abort();
setConfig('scan_running', '0');
return c.redirect('/scan');
return c.json({ ok: true });
});
// ─── SSE stream ───────────────────────────────────────────────────────────────
// ─── SSE ──────────────────────────────────────────────────────────────────────
app.get('/events', (c) => {
return stream(c, async (s) => {
@@ -93,12 +96,10 @@ app.get('/events', (c) => {
} else {
await new Promise<void>((res) => {
resolve = res;
setTimeout(res, 15_000); // keepalive every 15s
setTimeout(res, 25_000);
});
resolve = null;
if (queue.length === 0) {
await s.write(': keepalive\n\n');
}
if (queue.length === 0) await s.write(': keepalive\n\n');
}
}
} finally {
@@ -109,39 +110,42 @@ app.get('/events', (c) => {
// ─── Core scan logic ──────────────────────────────────────────────────────────
async function runScan(): Promise<void> {
async function runScan(limit: number | null = null): Promise<void> {
scanAbort = new AbortController();
const { signal } = scanAbort;
const isDev = process.env.NODE_ENV === 'development';
const db = getDb();
const cfg = getAllConfig();
const jellyfinCfg = {
url: cfg.jellyfin_url,
apiKey: cfg.jellyfin_api_key,
userId: cfg.jellyfin_user_id,
};
if (isDev) {
db.prepare('DELETE FROM stream_decisions').run();
db.prepare('DELETE FROM review_plans').run();
db.prepare('DELETE FROM media_streams').run();
db.prepare('DELETE FROM media_items').run();
}
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 processed = 0;
let errors = 0;
let total = 0;
let total = isDev ? 250 : 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 */ }
if (!isDev) {
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 = limit != null ? Math.min(limit, body.TotalRecordCount) : body.TotalRecordCount;
}
} catch { /* ignore */ }
}
const upsertItem = db.prepare(`
INSERT INTO media_items (
@@ -150,29 +154,16 @@ async function runScan(): Promise<void> {
original_language, orig_lang_source, needs_review,
imdb_id, tmdb_id, tvdb_id,
scan_status, last_scanned_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
'scanned', datetime('now')
)
) 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')
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 = ?');
@@ -183,38 +174,31 @@ async function runScan(): Promise<void> {
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
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
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)) {
const itemSource = isDev ? getDevItems(jellyfinCfg) : getAllItems(jellyfinCfg);
for await (const jellyfinItem of itemSource) {
if (signal.aborted) break;
if (!isDev && limit != null && processed >= limit) break;
if (!jellyfinItem.Name || !jellyfinItem.Path) {
console.warn(`Skipping item without name/path: id=${jellyfinItem.Id}`);
continue;
}
scanned++;
emitSse('progress', {
scanned,
total,
current_item: jellyfinItem.Name,
errors,
running: true,
});
processed++;
emitSse('progress', { scanned: processed, total, current_item: jellyfinItem.Name, errors, running: true });
try {
const providerIds = jellyfinItem.ProviderIds ?? {};
@@ -222,120 +206,56 @@ async function runScan(): Promise<void> {
const tmdbId = providerIds['Tmdb'] ?? null;
const tvdbId = providerIds['Tvdb'] ?? null;
// Determine original language
let origLang: string | null = extractOriginalLanguage(jellyfinItem);
let origLangSource: string = 'jellyfin';
let origLangSource = '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';
}
const lang = await radarrLang({ url: cfg.radarr_url, apiKey: cfg.radarr_api_key }, { tmdbId: tmdbId ?? undefined, imdbId: imdbId ?? undefined });
if (lang) { if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; origLang = lang; 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';
}
const lang = await sonarrLang({ url: cfg.sonarr_url, apiKey: cfg.sonarr_api_key }, tvdbId);
if (lang) { if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; origLang = lang; 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
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 ?? []) {
if (jStream.IsExternal) continue; // skip external subs — not embedded in container
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
);
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 }
);
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);
}
for (const dec of analysis.decisions) upsertDecision.run(planRow.id, 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 */ }
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 });
emitSse('complete', { scanned: processed, total, errors });
}
export default app;

102
server/api/setup.ts Normal file
View File

@@ -0,0 +1,102 @@
import { Hono } from 'hono';
import { setConfig, getAllConfig, getDb, getEnvLockedKeys } 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';
const app = new Hono();
app.get('/', (c) => {
const config = getAllConfig();
const envLocked = Array.from(getEnvLockedKeys());
return c.json({ config, envLocked });
});
app.post('/jellyfin', async (c) => {
const body = await c.req.json<{ url: string; api_key: string }>();
const url = body.url?.replace(/\/$/, '');
const apiKey = body.api_key;
if (!url || !apiKey) return c.json({ ok: false, error: 'URL and API key are required' }, 400);
const result = await testJellyfin({ url, apiKey });
if (!result.ok) return c.json({ ok: false, error: result.error });
setConfig('jellyfin_url', url);
setConfig('jellyfin_api_key', apiKey);
setConfig('setup_complete', '1');
try {
const users = await getUsers({ url, apiKey });
const admin = users.find((u) => u.Name === 'admin') ?? users[0];
if (admin?.Id) setConfig('jellyfin_user_id', admin.Id);
} catch { /* ignore */ }
return c.json({ ok: true });
});
app.post('/radarr', async (c) => {
const body = await c.req.json<{ url?: string; api_key?: string }>();
const url = body.url?.replace(/\/$/, '');
const apiKey = body.api_key;
if (!url || !apiKey) {
setConfig('radarr_enabled', '0');
return c.json({ ok: false, error: 'URL and API key are required' }, 400);
}
const result = await testRadarr({ url, apiKey });
if (!result.ok) return c.json({ ok: false, error: result.error });
setConfig('radarr_url', url);
setConfig('radarr_api_key', apiKey);
setConfig('radarr_enabled', '1');
return c.json({ ok: true });
});
app.post('/sonarr', async (c) => {
const body = await c.req.json<{ url?: string; api_key?: string }>();
const url = body.url?.replace(/\/$/, '');
const apiKey = body.api_key;
if (!url || !apiKey) {
setConfig('sonarr_enabled', '0');
return c.json({ ok: false, error: 'URL and API key are required' }, 400);
}
const result = await testSonarr({ url, apiKey });
if (!result.ok) return c.json({ ok: false, error: result.error });
setConfig('sonarr_url', url);
setConfig('sonarr_api_key', apiKey);
setConfig('sonarr_enabled', '1');
return c.json({ ok: true });
});
app.post('/subtitle-languages', async (c) => {
const body = await c.req.json<{ langs: string[] }>();
if (body.langs?.length > 0) {
setConfig('subtitle_languages', JSON.stringify(body.langs));
}
return c.json({ ok: true });
});
app.post('/paths', async (c) => {
const body = await c.req.json<{ movies_path?: string; series_path?: string }>();
const moviesPath = (body.movies_path ?? '').trim().replace(/\/$/, '');
const seriesPath = (body.series_path ?? '').trim().replace(/\/$/, '');
setConfig('movies_path', moviesPath);
setConfig('series_path', seriesPath);
return c.json({ ok: true });
});
app.post('/clear-scan', (c) => {
const db = getDb();
db.prepare('DELETE FROM media_items').run();
db.prepare("UPDATE config SET value = '0' WHERE key = 'scan_running'").run();
return c.json({ ok: true });
});
export default app;

202
server/api/subtitles.ts Normal file
View File

@@ -0,0 +1,202 @@
import { Hono } from 'hono';
import { getDb, getAllConfig } from '../db/index';
import { buildExtractOnlyCommand, buildDockerExtractOnlyCommand, predictExtractedFiles } from '../services/ffmpeg';
import { normalizeLanguage, getItem, refreshItem, mapStream } from '../services/jellyfin';
import type { MediaItem, MediaStream, SubtitleFile, ReviewPlan, StreamDecision } from '../types';
import { unlinkSync } from 'node:fs';
const app = new Hono();
// ─── Helpers ─────────────────────────────────────────────────────────────────
function loadDetail(db: ReturnType<typeof getDb>, itemId: number) {
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(itemId) as MediaItem | undefined;
if (!item) return null;
const subtitleStreams = db.prepare("SELECT * FROM media_streams WHERE item_id = ? AND type = 'Subtitle' ORDER BY stream_index").all(itemId) as MediaStream[];
const files = db.prepare('SELECT * FROM subtitle_files WHERE item_id = ? ORDER BY file_path').all(itemId) as SubtitleFile[];
const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(itemId) as ReviewPlan | undefined;
const decisions = plan
? db.prepare("SELECT sd.* FROM stream_decisions sd JOIN media_streams ms ON ms.id = sd.stream_id WHERE sd.plan_id = ? AND ms.type = 'Subtitle'").all(plan.id) as StreamDecision[]
: [];
const allStreams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(itemId) as MediaStream[];
const extractCommand = buildExtractOnlyCommand(item, allStreams);
const cfg = getAllConfig();
const dockerResult = buildDockerExtractOnlyCommand(item, allStreams, { moviesPath: cfg.movies_path || undefined, seriesPath: cfg.series_path || undefined });
return {
item,
subtitleStreams,
files,
plan: plan ?? null,
decisions,
subs_extracted: plan?.subs_extracted ?? 0,
extractCommand,
dockerCommand: dockerResult?.command ?? null,
dockerMountDir: dockerResult?.mountDir ?? null,
};
}
// ─── List ────────────────────────────────────────────────────────────────────
app.get('/', (c) => {
const db = getDb();
const filter = c.req.query('filter') ?? 'all';
let where = '1=1';
switch (filter) {
case 'not_extracted': where = 'rp.subs_extracted = 0 AND sub_count > 0'; break;
case 'extracted': where = 'rp.subs_extracted = 1'; break;
case 'no_subs': where = 'sub_count = 0'; break;
}
const rows = db.prepare(`
SELECT mi.id, mi.jellyfin_id, mi.type, mi.name, mi.series_name, mi.season_number,
mi.episode_number, mi.year, mi.original_language, mi.file_path,
rp.subs_extracted,
(SELECT COUNT(*) FROM media_streams ms WHERE ms.item_id = mi.id AND ms.type = 'Subtitle') as sub_count,
(SELECT COUNT(*) FROM subtitle_files sf WHERE sf.item_id = mi.id) as file_count
FROM media_items mi
LEFT JOIN review_plans rp ON rp.item_id = mi.id
WHERE ${where}
ORDER BY mi.name
LIMIT 500
`).all() as (Pick<MediaItem, 'id' | 'jellyfin_id' | 'type' | 'name' | 'series_name' | 'season_number' | 'episode_number' | 'year' | 'original_language' | 'file_path'> & {
subs_extracted: number | null;
sub_count: number;
file_count: number;
})[];
const totalAll = (db.prepare('SELECT COUNT(*) as n FROM media_items').get() as { n: number }).n;
const totalExtracted = (db.prepare('SELECT COUNT(*) as n FROM review_plans WHERE subs_extracted = 1').get() as { n: number }).n;
const totalNoSubs = (db.prepare(`
SELECT COUNT(*) as n FROM media_items mi
WHERE NOT EXISTS (SELECT 1 FROM media_streams ms WHERE ms.item_id = mi.id AND ms.type = 'Subtitle')
`).get() as { n: number }).n;
const totalNotExtracted = totalAll - totalExtracted - totalNoSubs;
return c.json({
items: rows,
filter,
totalCounts: { all: totalAll, not_extracted: totalNotExtracted, extracted: totalExtracted, no_subs: totalNoSubs },
});
});
// ─── Detail ──────────────────────────────────────────────────────────────────
app.get('/:id', (c) => {
const db = getDb();
const detail = loadDetail(db, Number(c.req.param('id')));
if (!detail) return c.notFound();
return c.json(detail);
});
// ─── Edit stream language ────────────────────────────────────────────────────
app.patch('/:id/stream/:streamId/language', async (c) => {
const db = getDb();
const itemId = Number(c.req.param('id'));
const streamId = Number(c.req.param('streamId'));
const body = await c.req.json<{ language: string }>();
const lang = (body.language ?? '').trim() || null;
const stream = db.prepare('SELECT * FROM media_streams WHERE id = ? AND item_id = ?').get(streamId, itemId) as MediaStream | undefined;
if (!stream) return c.notFound();
const normalized = lang ? normalizeLanguage(lang) : null;
db.prepare('UPDATE media_streams SET language = ? WHERE id = ?').run(normalized, streamId);
const detail = loadDetail(db, itemId);
if (!detail) return c.notFound();
return c.json(detail);
});
// ─── Edit stream title ──────────────────────────────────────────────────────
app.patch('/:id/stream/:streamId/title', async (c) => {
const db = getDb();
const itemId = Number(c.req.param('id'));
const streamId = Number(c.req.param('streamId'));
const body = await c.req.json<{ title: string }>();
const title = (body.title ?? '').trim() || null;
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 custom_title = ? WHERE plan_id = ? AND stream_id = ?').run(title, plan.id, streamId);
const detail = loadDetail(db, itemId);
if (!detail) return c.notFound();
return c.json(detail);
});
// ─── Extract ─────────────────────────────────────────────────────────────────
app.post('/:id/extract', (c) => {
const db = getDb();
const id = Number(c.req.param('id'));
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(id) as MediaItem | undefined;
if (!item) return c.notFound();
const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(id) as ReviewPlan | undefined;
if (plan?.subs_extracted) return c.json({ ok: false, error: 'Subtitles already extracted' }, 409);
const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(id) as MediaStream[];
const command = buildExtractOnlyCommand(item, streams);
if (!command) return c.json({ ok: false, error: 'No subtitles to extract' }, 400);
db.prepare("INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')").run(id, command);
return c.json({ ok: true });
});
// ─── Delete file ─────────────────────────────────────────────────────────────
app.delete('/:id/files/:fileId', (c) => {
const db = getDb();
const itemId = Number(c.req.param('id'));
const fileId = Number(c.req.param('fileId'));
const file = db.prepare('SELECT * FROM subtitle_files WHERE id = ? AND item_id = ?').get(fileId, itemId) as SubtitleFile | undefined;
if (!file) return c.notFound();
try { unlinkSync(file.file_path); } catch { /* file may not exist */ }
db.prepare('DELETE FROM subtitle_files WHERE id = ?').run(fileId);
const files = db.prepare('SELECT * FROM subtitle_files WHERE item_id = ? ORDER BY file_path').all(itemId) as SubtitleFile[];
return c.json({ ok: true, files });
});
// ─── Rescan ──────────────────────────────────────────────────────────────────
app.post('/:id/rescan', async (c) => {
const db = getDb();
const id = Number(c.req.param('id'));
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(id) as MediaItem | undefined;
if (!item) return c.notFound();
const cfg = getAllConfig();
const jfCfg = { url: cfg.jellyfin_url, apiKey: cfg.jellyfin_api_key, userId: cfg.jellyfin_user_id };
await refreshItem(jfCfg, item.jellyfin_id);
const fresh = await getItem(jfCfg, item.jellyfin_id);
if (fresh) {
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
db.prepare('DELETE FROM media_streams WHERE item_id = ?').run(id);
for (const jStream of fresh.MediaStreams ?? []) {
if (jStream.IsExternal) continue;
const s = mapStream(jStream);
insertStream.run(id, 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);
}
}
const detail = loadDetail(db, id);
if (!detail) return c.notFound();
return c.json(detail);
});
export default app;

110
server/db/index.ts Normal file
View File

@@ -0,0 +1,110 @@
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 isDev = process.env.NODE_ENV === 'development';
const dbPath = join(dataDir, isDev ? 'netfelix-dev.db' : 'netfelix.db');
// ─── Env-var → config key mapping ─────────────────────────────────────────────
const ENV_MAP: Record<string, string> = {
jellyfin_url: 'JELLYFIN_URL',
jellyfin_api_key: 'JELLYFIN_API_KEY',
jellyfin_user_id: 'JELLYFIN_USER_ID',
radarr_url: 'RADARR_URL',
radarr_api_key: 'RADARR_API_KEY',
radarr_enabled: 'RADARR_ENABLED',
sonarr_url: 'SONARR_URL',
sonarr_api_key: 'SONARR_API_KEY',
sonarr_enabled: 'SONARR_ENABLED',
subtitle_languages: 'SUBTITLE_LANGUAGES',
movies_path: 'MOVIES_PATH',
series_path: 'SERIES_PATH',
};
/** Read a config key from environment variables (returns null if not set). */
function envValue(key: string): string | null {
const envKey = ENV_MAP[key];
if (!envKey) return null;
const val = process.env[envKey];
if (!val) return null;
if (key.endsWith('_enabled')) return val === '1' || val.toLowerCase() === 'true' ? '1' : '0';
if (key === 'subtitle_languages') return JSON.stringify(val.split(',').map((s) => s.trim()));
if (key.endsWith('_url')) return val.replace(/\/$/, '');
return val;
}
/** True when minimum required Jellyfin env vars are present — skips the setup wizard. */
function isEnvConfigured(): boolean {
return !!(process.env.JELLYFIN_URL && process.env.JELLYFIN_API_KEY);
}
// ─── Database ──────────────────────────────────────────────────────────────────
let _db: Database | null = null;
export function getDb(): Database {
if (_db) return _db;
_db = new Database(dbPath, { create: true });
_db.exec(SCHEMA);
// Migrations for columns added after initial release
try { _db.exec('ALTER TABLE stream_decisions ADD COLUMN custom_title TEXT'); } catch { /* already exists */ }
try { _db.exec('ALTER TABLE review_plans ADD COLUMN subs_extracted INTEGER NOT NULL DEFAULT 0'); } catch { /* already exists */ }
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 {
// Env vars take precedence over DB
const fromEnv = envValue(key);
if (fromEnv !== null) return fromEnv;
// Auto-complete setup when all required Jellyfin env vars are present
if (key === 'setup_complete' && isEnvConfigured()) return '1';
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);
}
/** Returns the set of config keys currently overridden by environment variables. */
export function getEnvLockedKeys(): Set<string> {
const locked = new Set<string>();
for (const key of Object.keys(ENV_MAP)) {
if (envValue(key) !== null) locked.add(key);
}
return locked;
}
export function getAllConfig(): Record<string, string> {
const rows = getDb()
.prepare('SELECT key, value FROM config')
.all() as { key: string; value: string }[];
const result = Object.fromEntries(rows.map((r) => [r.key, r.value ?? '']));
// Apply env overrides on top of DB values
for (const key of Object.keys(ENV_MAP)) {
const fromEnv = envValue(key);
if (fromEnv !== null) result[key] = fromEnv;
}
// Auto-complete setup when all required Jellyfin env vars are present
if (isEnvConfigured()) result.setup_complete = '1';
return result;
}

View File

@@ -81,9 +81,22 @@ CREATE TABLE IF NOT EXISTS stream_decisions (
stream_id INTEGER NOT NULL REFERENCES media_streams(id) ON DELETE CASCADE,
action TEXT NOT NULL,
target_index INTEGER,
custom_title TEXT,
UNIQUE(plan_id, stream_id)
);
CREATE TABLE IF NOT EXISTS subtitle_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
file_path TEXT NOT NULL UNIQUE,
language TEXT,
codec TEXT,
is_forced INTEGER NOT NULL DEFAULT 0,
is_hearing_impaired INTEGER NOT NULL DEFAULT 0,
file_size INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
@@ -111,4 +124,6 @@ export const DEFAULT_CONFIG: Record<string, string> = {
sonarr_enabled: '0',
subtitle_languages: JSON.stringify(['eng', 'deu', 'spa']),
scan_running: '0',
movies_path: '',
series_path: '',
};

62
server/index.tsx Normal file
View File

@@ -0,0 +1,62 @@
import { Hono } from 'hono';
import { serveStatic } from 'hono/bun';
import { cors } from 'hono/cors';
import { getDb, getConfig } from './db/index';
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';
import subtitlesRoutes from './api/subtitles';
import dashboardRoutes from './api/dashboard';
const app = new Hono();
// ─── CORS (dev: Vite on :5173 talks to Hono on :3000) ────────────────────────
app.use('/api/*', cors({ origin: ['http://localhost:5173', 'http://localhost:3000'] }));
// ─── API routes ───────────────────────────────────────────────────────────────
app.route('/api/dashboard', dashboardRoutes);
app.route('/api/setup', setupRoutes);
app.route('/api/scan', scanRoutes);
app.route('/api/review', reviewRoutes);
app.route('/api/execute', executeRoutes);
app.route('/api/subtitles', subtitlesRoutes);
app.route('/api/nodes', nodesRoutes);
// ─── Static assets (production: serve Vite build) ────────────────────────────
app.use('/assets/*', serveStatic({ root: './dist' }));
app.use('/favicon.ico', serveStatic({ path: './dist/favicon.ico' }));
// ─── SPA fallback ─────────────────────────────────────────────────────────────
// All non-API routes serve the React index.html so TanStack Router handles them.
app.get('*', (c) => {
const accept = c.req.header('Accept') ?? '';
if (c.req.path.startsWith('/api/')) return c.notFound();
// In dev the Vite server handles the SPA. In production serve dist/index.html.
try {
const html = Bun.file('./dist/index.html').text();
return html.then((text) => c.html(text));
} catch {
return c.text('Run `bun build` first to generate the frontend.', 503);
}
});
// ─── Start ────────────────────────────────────────────────────────────────────
const port = Number(process.env.PORT ?? '3000');
console.log(`netfelix-audio-fix API on http://localhost:${port}`);
getDb();
export default {
port,
fetch: app.fetch,
idleTimeout: 0,
};

133
server/services/analyzer.ts Normal file
View File

@@ -0,0 +1,133 @@
import type { MediaItem, MediaStream, PlanResult } from '../types';
import { normalizeLanguage } from './jellyfin';
export interface AnalyzerConfig {
subtitleLanguages: string[]; // kept for potential future use
}
/**
* Given an item and its streams, compute what action to take for each stream
* and whether the file needs audio remuxing.
*
* Subtitles are ALWAYS removed from the container (they get extracted to
* sidecar files). is_noop only considers audio changes.
*/
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 notes: string[] = [];
// Compute action for each stream
const decisions: PlanResult['decisions'] = streams.map((s) => {
const action = decideAction(s, origLang);
return { stream_id: s.id, action, target_index: null };
});
// Audio-only noop: only consider audio removals/reordering
// (subtitles are always removed from container — that's implicit, not a "change" to review)
const anyAudioRemoved = streams.some((s, i) => s.type === 'Audio' && decisions[i].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 audio ordering changes
const audioOrderChanged = checkAudioOrderChanged(streams, decisions);
const isNoop = !anyAudioRemoved && !audioOrderChanged;
const hasSubs = streams.some((s) => s.type === 'Subtitle');
// Generate notes for edge cases
if (!origLang && item.needs_review) {
notes.push('Original language unknown — audio tracks not filtered; manual review required');
}
return {
is_noop: isNoop,
has_subs: hasSubs,
decisions,
notes: notes.length > 0 ? notes.join('\n') : null,
};
}
function decideAction(
stream: MediaStream,
origLang: string | null,
): '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':
// All subtitles are removed from the container and extracted to sidecar files
return '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;
});
}
// 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;
});
}
}
/** Check if audio stream ordering changes (ignores subtitles which are always removed). */
function checkAudioOrderChanged(
streams: MediaStream[],
decisions: PlanResult['decisions']
): boolean {
const keptAudio = streams.filter((s) => {
if (s.type !== 'Audio') return false;
const dec = decisions.find((d) => d.stream_id === s.id);
return dec?.action === 'keep';
});
const sorted = [...keptAudio].sort((a, b) => a.stream_index - b.stream_index);
for (let i = 0; i < keptAudio.length; i++) {
const dec = decisions.find((d) => d.stream_id === keptAudio[i].id);
if (!dec) continue;
const currentPos = sorted.findIndex((s) => s.id === keptAudio[i].id);
if (dec.target_index !== null && dec.target_index !== currentPos) return true;
}
return false;
}

520
server/services/ffmpeg.ts Normal file
View File

@@ -0,0 +1,520 @@
import type { MediaItem, MediaStream, StreamDecision } from '../types';
import { normalizeLanguage } from './jellyfin';
// ─── Subtitle extraction helpers ──────────────────────────────────────────────
/** ISO 639-2/B → ISO 639-1 two-letter codes for subtitle filenames. */
const ISO639_1: Record<string, string> = {
eng: 'en', deu: 'de', spa: 'es', fra: 'fr', ita: 'it',
por: 'pt', jpn: 'ja', kor: 'ko', zho: 'zh', ara: 'ar',
rus: 'ru', nld: 'nl', swe: 'sv', nor: 'no', dan: 'da',
fin: 'fi', pol: 'pl', tur: 'tr', tha: 'th', hin: 'hi',
hun: 'hu', ces: 'cs', ron: 'ro', ell: 'el', heb: 'he',
fas: 'fa', ukr: 'uk', ind: 'id', cat: 'ca', nob: 'nb',
nno: 'nn', isl: 'is', hrv: 'hr', slk: 'sk', bul: 'bg',
srp: 'sr', slv: 'sl', lav: 'lv', lit: 'lt', est: 'et',
};
/** Subtitle codec → external file extension. */
const SUBTITLE_EXT: Record<string, string> = {
subrip: 'srt', srt: 'srt', ass: 'ass', ssa: 'ssa',
webvtt: 'vtt', vtt: 'vtt',
hdmv_pgs_subtitle: 'sup', pgssub: 'sup',
dvd_subtitle: 'sub', dvbsub: 'sub',
mov_text: 'srt', text: 'srt',
};
function subtitleLang2(lang: string | null): string {
if (!lang) return 'und';
const n = normalizeLanguage(lang);
return ISO639_1[n] ?? n;
}
/** Returns the ffmpeg codec name to use when extracting this subtitle stream. */
function subtitleCodecArg(codec: string | null): string {
if (!codec) return 'copy';
return codec.toLowerCase() === 'mov_text' ? 'subrip' : 'copy';
}
function subtitleExtForCodec(codec: string | null): string {
if (!codec) return 'srt';
return SUBTITLE_EXT[codec.toLowerCase()] ?? 'srt';
}
/**
* Build ffmpeg output args for extracting ALL subtitle streams
* to external sidecar files next to the video.
*
* Returns a flat array of args to append after the main output in the
* command. Each subtitle becomes a separate ffmpeg output:
* -map 0:s:N -c:s copy 'basename.en.srt'
*
* @param allStreams All streams for the item (needed to compute type-relative indices)
* @param basePath Video file path without extension (host or /work path)
*/
interface ExtractionEntry {
stream: MediaStream;
typeIdx: number;
outPath: string;
codecArg: string;
}
/** Compute extraction metadata for all subtitle streams. Shared by buildExtractionOutputs and predictExtractedFiles. */
function computeExtractionEntries(
allStreams: MediaStream[],
basePath: string
): ExtractionEntry[] {
const subTypeIdx = new Map<number, number>();
let subCount = 0;
for (const s of [...allStreams].sort((a, b) => a.stream_index - b.stream_index)) {
if (s.type === 'Subtitle') subTypeIdx.set(s.id, subCount++);
}
const allSubs = allStreams
.filter((s) => s.type === 'Subtitle')
.sort((a, b) => a.stream_index - b.stream_index);
if (allSubs.length === 0) return [];
const usedNames = new Set<string>();
const entries: ExtractionEntry[] = [];
for (const s of allSubs) {
const typeIdx = subTypeIdx.get(s.id) ?? 0;
const langCode = subtitleLang2(s.language);
const ext = subtitleExtForCodec(s.codec);
const codecArg = subtitleCodecArg(s.codec);
const nameParts = [langCode];
if (s.is_forced) nameParts.push('forced');
if (s.is_hearing_impaired) nameParts.push('hi');
let outPath = `${basePath}.${nameParts.join('.')}.${ext}`;
let counter = 2;
while (usedNames.has(outPath)) {
outPath = `${basePath}.${nameParts.join('.')}.${counter}.${ext}`;
counter++;
}
usedNames.add(outPath);
entries.push({ stream: s, typeIdx, outPath, codecArg });
}
return entries;
}
function buildExtractionOutputs(
allStreams: MediaStream[],
basePath: string
): string[] {
const entries = computeExtractionEntries(allStreams, basePath);
const args: string[] = [];
for (const e of entries) {
args.push(`-map 0:s:${e.typeIdx}`, `-c:s ${e.codecArg}`, shellQuote(e.outPath));
}
return args;
}
/**
* Predict the sidecar files that subtitle extraction will create.
* Used to populate the subtitle_files table after a successful job.
*/
export function predictExtractedFiles(
item: MediaItem,
streams: MediaStream[]
): Array<{ file_path: string; language: string | null; codec: string | null; is_forced: boolean; is_hearing_impaired: boolean }> {
const basePath = item.file_path.replace(/\.[^.]+$/, '');
const entries = computeExtractionEntries(streams, basePath);
return entries.map((e) => ({
file_path: e.outPath,
language: e.stream.language,
codec: e.stream.codec,
is_forced: !!e.stream.is_forced,
is_hearing_impaired: !!e.stream.is_hearing_impaired,
}));
}
// ─────────────────────────────────────────────────────────────────────────────
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',
cat: 'Catalan', nob: 'Norwegian Bokmål', nno: 'Norwegian Nynorsk',
isl: 'Icelandic', slk: 'Slovak', hrv: 'Croatian', bul: 'Bulgarian',
srp: 'Serbian', slv: 'Slovenian', lav: 'Latvian', lit: 'Lithuanian',
est: 'Estonian',
};
function trackTitle(stream: MediaStream): string | null {
if (stream.type === 'Subtitle') {
// Subtitles always get a clean language-based title so Jellyfin displays
// "German", "English (Forced)", etc. regardless of the original file title.
// The review UI shows a ⚠ badge when the original title looks like a
// different language, so users can spot and remove mislabeled tracks.
if (!stream.language) return null;
const lang = normalizeLanguage(stream.language);
const base = LANG_NAMES[lang] ?? lang.toUpperCase();
if (stream.is_forced) return `${base} (Forced)`;
if (stream.is_hearing_impaired) return `${base} (CC)`;
return base;
}
// For audio and other stream types: preserve any existing title
// (e.g. "Director's Commentary") and fall back to language name.
if (stream.title) return stream.title;
if (!stream.language) return null;
const lang = normalizeLanguage(stream.language);
return LANG_NAMES[lang] ?? lang.toUpperCase();
}
const TYPE_SPEC: Record<string, string> = { Video: 'v', Audio: 'a', Subtitle: 's' };
/**
* Build -map flags using type-relative specifiers (0:v:N, 0:a:N, 0:s:N).
*
* Jellyfin's stream_index is an absolute index that can include EmbeddedImage
* and Data streams which ffmpeg may count differently (e.g. cover art stored
* as attachments). Using the stream's position within its own type group
* matches ffmpeg's 0:a:N convention exactly and avoids silent mismatches.
*/
function buildMaps(
allStreams: MediaStream[],
kept: { stream: MediaStream; dec: StreamDecision }[]
): string[] {
// Map each stream id → its 0-based position among streams of the same type,
// sorted by stream_index (the order ffmpeg sees them in the input).
const typePos = new Map<number, number>();
const counts: Record<string, number> = {};
for (const s of [...allStreams].sort((a, b) => a.stream_index - b.stream_index)) {
if (!TYPE_SPEC[s.type]) continue;
const n = counts[s.type] ?? 0;
typePos.set(s.id, n);
counts[s.type] = n + 1;
}
return kept
.filter((k) => !!TYPE_SPEC[k.stream.type])
.map((k) => `-map 0:${TYPE_SPEC[k.stream.type]}:${typePos.get(k.stream.id) ?? 0}`);
}
/**
* Build disposition and metadata flags for kept audio + subtitle streams.
* - Marks the first kept audio stream as default, clears all others.
* - Sets harmonized language-name titles on all kept audio/subtitle streams.
*/
function buildStreamFlags(
kept: { stream: MediaStream; dec: StreamDecision }[]
): string[] {
const audioKept = kept.filter((k) => k.stream.type === 'Audio');
const subKept = kept.filter((k) => k.stream.type === 'Subtitle');
const args: string[] = [];
// Disposition: first audio = default, rest = clear
audioKept.forEach((_, i) => {
args.push(`-disposition:a:${i}`, i === 0 ? 'default' : '0');
});
// Titles for audio streams (custom_title overrides generated title)
audioKept.forEach((k, i) => {
const title = k.dec.custom_title ?? trackTitle(k.stream);
if (title) args.push(`-metadata:s:a:${i}`, `title=${shellQuote(title)}`);
});
// Titles for subtitle streams (custom_title overrides generated title)
subKept.forEach((k, i) => {
const title = k.dec.custom_title ?? trackTitle(k.stream);
if (title) args.push(`-metadata:s:s:${i}`, `title=${shellQuote(title)}`);
});
return args;
}
/**
* 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 basePath = inputPath.replace(/\.[^.]+$/, '');
const maps = buildMaps(streams, kept);
const streamFlags = buildStreamFlags(kept);
const extractionOutputs = buildExtractionOutputs(streams, basePath);
const parts: string[] = [
'ffmpeg',
'-y',
'-i', shellQuote(inputPath),
...maps,
...streamFlags,
'-c copy',
shellQuote(tmpPath),
...extractionOutputs,
'&&',
'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 basePath = outputPath.replace(/\.[^.]+$/, '');
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 = buildMaps(streams, kept);
const streamFlags = buildStreamFlags(kept);
const extractionOutputs = buildExtractionOutputs(streams, basePath);
return [
'ffmpeg', '-y',
'-i', shellQuote(inputPath),
...maps,
...streamFlags,
'-c copy',
'-f matroska',
shellQuote(tmpPath),
...extractionOutputs,
'&&',
'mv', shellQuote(tmpPath), shellQuote(outputPath),
].join(' ');
}
/**
* Build a Docker-wrapped version of the FFmpeg command.
* Mounts the file's directory to /work inside the container and rewrites
* all paths accordingly. Requires only Docker as a system dependency.
*
* Image: jrottenberg/ffmpeg — entrypoint is ffmpeg, so we use --entrypoint sh
* to run ffmpeg + mv in a single shell invocation.
*/
export function buildDockerCommand(
item: MediaItem,
streams: MediaStream[],
decisions: StreamDecision[],
opts: { moviesPath?: string; seriesPath?: string } = {}
): { command: string; mountDir: string } {
const inputPath = item.file_path;
const isEpisode = item.type === 'Episode';
let mountDir: string;
let relPath: string;
const hostRoot = (isEpisode ? opts.seriesPath : opts.moviesPath)?.replace(/\/$/, '') ?? '';
// Jellyfin always mounts libraries at /movies and /series by convention
const jellyfinPrefix = isEpisode ? '/series' : '/movies';
if (hostRoot) {
mountDir = hostRoot;
if (inputPath.startsWith(jellyfinPrefix + '/')) {
relPath = inputPath.slice(jellyfinPrefix.length); // keeps leading /
} else {
// Path doesn't match the expected prefix — strip 1 component as best effort
const components = inputPath.split('/').filter(Boolean);
relPath = '/' + components.slice(1).join('/');
}
} else {
// No host path configured — fall back to mounting the file's immediate parent directory
const lastSlash = inputPath.lastIndexOf('/');
mountDir = lastSlash >= 0 ? inputPath.slice(0, lastSlash) : '.';
relPath = '/' + (lastSlash >= 0 ? inputPath.slice(lastSlash + 1) : inputPath);
}
const ext = relPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv';
const tmpRelPath = relPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
const workInput = `/work${relPath}`;
const workTmp = `/work${tmpRelPath}`;
const workBasePath = workInput.replace(/\.[^.]+$/, '');
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, 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 maps = buildMaps(streams, kept);
const streamFlags = buildStreamFlags(kept);
// Subtitle extraction uses /work paths so files land in the mounted directory
const extractionOutputs = buildExtractionOutputs(streams, workBasePath);
// The jrottenberg/ffmpeg entrypoint IS ffmpeg — run it directly so no inner
// shell is needed and no nested quoting is required. The mv step runs on the
// host (outside Docker) so it uses the real host paths.
const hostInput = mountDir + relPath;
const hostTmp = mountDir + tmpRelPath;
const parts = [
'docker run --rm',
`-v ${shellQuote(mountDir + ':/work')}`,
'jrottenberg/ffmpeg:latest',
'-y',
'-i', shellQuote(workInput),
...maps,
...streamFlags,
'-c copy',
shellQuote(workTmp),
...extractionOutputs,
'&&',
'mv', shellQuote(hostTmp), shellQuote(hostInput),
];
return { command: parts.join(' '), mountDir };
}
/**
* Build a command that ONLY extracts subtitles to sidecar files
* without modifying the container. Useful when the item is otherwise
* a noop but the user wants sidecar subtitle files.
*/
export function buildExtractOnlyCommand(
item: MediaItem,
streams: MediaStream[]
): string | null {
const basePath = item.file_path.replace(/\.[^.]+$/, '');
const extractionOutputs = buildExtractionOutputs(streams, basePath);
if (extractionOutputs.length === 0) return null;
return ['ffmpeg', '-y', '-i', shellQuote(item.file_path), ...extractionOutputs].join(' ');
}
/**
* Build a Docker command that ONLY extracts subtitles to sidecar files.
*/
export function buildDockerExtractOnlyCommand(
item: MediaItem,
streams: MediaStream[],
opts: { moviesPath?: string; seriesPath?: string } = {}
): { command: string; mountDir: string } | null {
const inputPath = item.file_path;
const isEpisode = item.type === 'Episode';
let mountDir: string;
let relPath: string;
const hostRoot = (isEpisode ? opts.seriesPath : opts.moviesPath)?.replace(/\/$/, '') ?? '';
const jellyfinPrefix = isEpisode ? '/series' : '/movies';
if (hostRoot) {
mountDir = hostRoot;
if (inputPath.startsWith(jellyfinPrefix + '/')) {
relPath = inputPath.slice(jellyfinPrefix.length);
} else {
const components = inputPath.split('/').filter(Boolean);
relPath = '/' + components.slice(1).join('/');
}
} else {
const lastSlash = inputPath.lastIndexOf('/');
mountDir = lastSlash >= 0 ? inputPath.slice(0, lastSlash) : '.';
relPath = '/' + (lastSlash >= 0 ? inputPath.slice(lastSlash + 1) : inputPath);
}
const workInput = `/work${relPath}`;
const workBasePath = workInput.replace(/\.[^.]+$/, '');
const extractionOutputs = buildExtractionOutputs(streams, workBasePath);
if (extractionOutputs.length === 0) return null;
const parts = [
'docker run --rm',
`-v ${shellQuote(mountDir + ':/work')}`,
'jrottenberg/ffmpeg:latest',
'-y',
'-i', shellQuote(workInput),
...extractionOutputs,
];
return { command: parts.join(' '), mountDir };
}
/** 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(' · ');
}

244
server/services/jellyfin.ts Normal file
View File

@@ -0,0 +1,244 @@
import type { JellyfinItem, JellyfinMediaStream, JellyfinUser, MediaStream } from '../types';
export interface JellyfinConfig {
url: string;
apiKey: string;
/** Optional: when omitted the server-level /Items endpoint is used (requires admin API key). */
userId?: string;
}
/** Build the base items URL: user-scoped when userId is set, server-level otherwise. */
function itemsBaseUrl(cfg: JellyfinConfig): string {
return cfg.userId ? `${cfg.url}/Users/${cfg.userId}/Items` : `${cfg.url}/Items`;
}
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[]>;
}
const ITEM_FIELDS = [
'MediaStreams',
'Path',
'ProviderIds',
'OriginalTitle',
'ProductionYear',
'Size',
'Container',
].join(',');
export async function* getAllItems(
cfg: JellyfinConfig,
onProgress?: (count: number, total: number) => void
): AsyncGenerator<JellyfinItem> {
let startIndex = 0;
let total = 0;
do {
const url = new URL(itemsBaseUrl(cfg));
url.searchParams.set('Recursive', 'true');
url.searchParams.set('IncludeItemTypes', 'Movie,Episode');
url.searchParams.set('Fields', ITEM_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);
}
/**
* Dev mode: yields 50 random movies + all episodes from 10 random series.
* Used instead of getAllItems() when NODE_ENV=development.
*/
export async function* getDevItems(cfg: JellyfinConfig): AsyncGenerator<JellyfinItem> {
// 50 random movies
const movieUrl = new URL(itemsBaseUrl(cfg));
movieUrl.searchParams.set('Recursive', 'true');
movieUrl.searchParams.set('IncludeItemTypes', 'Movie');
movieUrl.searchParams.set('SortBy', 'Random');
movieUrl.searchParams.set('Limit', '50');
movieUrl.searchParams.set('Fields', ITEM_FIELDS);
const movieRes = await fetch(movieUrl.toString(), { headers: headers(cfg.apiKey) });
if (!movieRes.ok) throw new Error(`Jellyfin movies failed: HTTP ${movieRes.status} — check JELLYFIN_URL and JELLYFIN_API_KEY`);
const movieBody = (await movieRes.json()) as { Items: JellyfinItem[] };
for (const item of movieBody.Items) yield item;
// 10 random series → yield all their episodes
const seriesUrl = new URL(itemsBaseUrl(cfg));
seriesUrl.searchParams.set('Recursive', 'true');
seriesUrl.searchParams.set('IncludeItemTypes', 'Series');
seriesUrl.searchParams.set('SortBy', 'Random');
seriesUrl.searchParams.set('Limit', '10');
const seriesRes = await fetch(seriesUrl.toString(), { headers: headers(cfg.apiKey) });
if (!seriesRes.ok) throw new Error(`Jellyfin series failed: HTTP ${seriesRes.status}`);
const seriesBody = (await seriesRes.json()) as { Items: Array<{ Id: string }> };
for (const series of seriesBody.Items) {
const epUrl = new URL(itemsBaseUrl(cfg));
epUrl.searchParams.set('ParentId', series.Id);
epUrl.searchParams.set('Recursive', 'true');
epUrl.searchParams.set('IncludeItemTypes', 'Episode');
epUrl.searchParams.set('Fields', ITEM_FIELDS);
const epRes = await fetch(epUrl.toString(), { headers: headers(cfg.apiKey) });
if (epRes.ok) {
const epBody = (await epRes.json()) as { Items: JellyfinItem[] };
for (const ep of epBody.Items) yield ep;
}
}
}
/** Fetch a single Jellyfin item by its ID (for per-file rescan). */
export async function getItem(cfg: JellyfinConfig, jellyfinId: string): Promise<JellyfinItem | null> {
const base = cfg.userId ? `${cfg.url}/Users/${cfg.userId}/Items/${jellyfinId}` : `${cfg.url}/Items/${jellyfinId}`;
const url = new URL(base);
url.searchParams.set('Fields', ITEM_FIELDS);
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
if (!res.ok) return null;
return res.json() as Promise<JellyfinItem>;
}
/**
* Trigger a Jellyfin metadata refresh for a single item and wait until it completes.
* Polls DateLastRefreshed until it changes (or timeout is reached).
*/
export async function refreshItem(cfg: JellyfinConfig, jellyfinId: string, timeoutMs = 15000): Promise<void> {
const itemUrl = `${cfg.url}/Items/${jellyfinId}`;
// 1. Snapshot current DateLastRefreshed
const beforeRes = await fetch(itemUrl, { headers: headers(cfg.apiKey) });
if (!beforeRes.ok) throw new Error(`Jellyfin item fetch failed: HTTP ${beforeRes.status}`);
const before = (await beforeRes.json()) as { DateLastRefreshed?: string };
const beforeDate = before.DateLastRefreshed;
// 2. Trigger refresh (returns 204 immediately; refresh runs async)
const refreshUrl = new URL(`${itemUrl}/Refresh`);
refreshUrl.searchParams.set('MetadataRefreshMode', 'FullRefresh');
refreshUrl.searchParams.set('ImageRefreshMode', 'None');
refreshUrl.searchParams.set('ReplaceAllMetadata', 'false');
refreshUrl.searchParams.set('ReplaceAllImages', 'false');
const refreshRes = await fetch(refreshUrl.toString(), { method: 'POST', headers: headers(cfg.apiKey) });
if (!refreshRes.ok) throw new Error(`Jellyfin refresh failed: HTTP ${refreshRes.status}`);
// 3. Poll until DateLastRefreshed changes
const start = Date.now();
while (Date.now() - start < timeoutMs) {
await new Promise((r) => setTimeout(r, 1000));
const checkRes = await fetch(itemUrl, { headers: headers(cfg.apiKey) });
if (!checkRes.ok) continue;
const check = (await checkRes.json()) as { DateLastRefreshed?: string };
if (check.DateLastRefreshed && check.DateLastRefreshed !== beforeDate) return;
}
// Timeout reached — proceed anyway (refresh may still complete in background)
}
/** 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;
}

View File

@@ -48,17 +48,31 @@ export interface ReviewPlan {
item_id: number;
status: 'pending' | 'approved' | 'skipped' | 'done' | 'error';
is_noop: number;
subs_extracted: number;
notes: string | null;
reviewed_at: string | null;
created_at: string;
}
export interface SubtitleFile {
id: number;
item_id: number;
file_path: string;
language: string | null;
codec: string | null;
is_forced: number;
is_hearing_impaired: number;
file_size: number | null;
created_at: string;
}
export interface StreamDecision {
id: number;
plan_id: number;
stream_id: number;
action: 'keep' | 'remove';
target_index: number | null;
custom_title: string | null;
}
export interface Job {
@@ -97,6 +111,7 @@ export interface StreamWithDecision extends MediaStream {
export interface PlanResult {
is_noop: boolean;
has_subs: boolean;
decisions: Array<{ stream_id: number; action: 'keep' | 'remove'; target_index: number | null }>;
notes: string | null;
}
@@ -113,6 +128,7 @@ export interface JellyfinMediaStream {
IsDefault?: boolean;
IsForced?: boolean;
IsHearingImpaired?: boolean;
IsExternal?: boolean;
Channels?: number;
ChannelLayout?: string;
BitRate?: number;

View File

@@ -1,255 +0,0 @@
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;

View File

@@ -1,73 +0,0 @@
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;

View File

@@ -1,305 +0,0 @@
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;

View File

@@ -1,103 +0,0 @@
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;

View File

@@ -1,48 +0,0 @@
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 ?? '']));
}

View File

@@ -0,0 +1,91 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from '@tanstack/react-router';
import { api } from '~/shared/lib/api';
import { Button } from '~/shared/components/ui/button';
import { Alert } from '~/shared/components/ui/alert';
interface Stats {
totalItems: number; scanned: number; needsAction: number;
approved: number; done: number; errors: number; noChange: number;
}
interface DashboardData { stats: Stats; scanRunning: boolean; setupComplete: boolean; }
function StatCard({ label, value, danger }: { label: string; value: number; danger?: boolean }) {
return (
<div className="border border-gray-200 rounded-lg px-3.5 py-2.5">
<div className={`text-[1.6rem] font-bold leading-[1.1] ${danger ? 'text-red-600' : ''}`}>
{value.toLocaleString()}
</div>
<div className="text-[0.7rem] text-gray-500 mt-0.5">{label}</div>
</div>
);
}
export function DashboardPage() {
const navigate = useNavigate();
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [starting, setStarting] = useState(false);
useEffect(() => {
api.get<DashboardData>('/api/dashboard').then((d) => {
setData(d);
setLoading(false);
if (!d.setupComplete) navigate({ to: '/setup' });
}).catch(() => setLoading(false));
}, [navigate]);
const startScan = async () => {
setStarting(true);
await api.post('/api/scan/start', {}).catch(() => {});
navigate({ to: '/scan' });
};
if (loading) return <div className="text-gray-400 py-8 text-center">Loading</div>;
if (!data) return <Alert variant="error">Failed to load dashboard.</Alert>;
const { stats, scanRunning } = data;
return (
<div>
<div className="flex items-center gap-3 mb-4">
<h1 className="text-xl font-bold m-0">Dashboard</h1>
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-2.5 mb-5">
<StatCard label="Total items" value={stats.totalItems} />
<StatCard label="Scanned" value={stats.scanned} />
<StatCard label="Needs action" value={stats.needsAction} />
<StatCard label="No change needed" value={stats.noChange} />
<StatCard label="Approved / queued" value={stats.approved} />
<StatCard label="Done" value={stats.done} />
{stats.errors > 0 && <StatCard label="Errors" value={stats.errors} danger />}
</div>
<div className="flex items-center gap-3 mb-8">
{scanRunning ? (
<Link to="/scan" className="inline-flex items-center justify-center gap-1 rounded px-3 py-1.5 text-sm font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline">
Scan running
</Link>
) : (
<Button onClick={startScan} disabled={starting}>
{starting ? 'Starting…' : '▶ Start Scan'}
</Button>
)}
<Link to="/review" className="inline-flex items-center justify-center gap-1 rounded px-3 py-1.5 text-sm font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline">
Review changes
</Link>
<Link to="/execute" className="inline-flex items-center justify-center gap-1 rounded px-3 py-1.5 text-sm font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline">
Execute jobs
</Link>
</div>
{stats.scanned === 0 && (
<Alert variant="info">
Library not scanned yet. Click <strong>Start Scan</strong> to begin.
</Alert>
)}
</div>
);
}

View File

@@ -0,0 +1,183 @@
import { useEffect, useRef, useState } from 'react';
import { Link } from '@tanstack/react-router';
import { api } from '~/shared/lib/api';
import { Badge } from '~/shared/components/ui/badge';
import { Button } from '~/shared/components/ui/button';
import { Select } from '~/shared/components/ui/select';
import type { Job, Node, MediaItem } from '~/shared/lib/types';
interface JobEntry { job: Job; item: MediaItem | null; node: Node | null; }
interface ExecuteData { jobs: JobEntry[]; nodes: Node[]; }
function itemName(job: Job, item: MediaItem | null): string {
if (!item) return `Item #${job.item_id}`;
if (item.type === 'Episode' && item.series_name) {
return `${item.series_name} S${String(item.season_number ?? 0).padStart(2, '0')}E${String(item.episode_number ?? 0).padStart(2, '0')}`;
}
return item.name;
}
export function ExecutePage() {
const [data, setData] = useState<ExecuteData | null>(null);
const [logs, setLogs] = useState<Map<number, string>>(new Map());
const [logVisible, setLogVisible] = useState<Set<number>>(new Set());
const esRef = useRef<EventSource | null>(null);
const load = () => api.get<ExecuteData>('/api/execute').then(setData);
useEffect(() => { load(); }, []);
// SSE for live job updates
useEffect(() => {
const es = new EventSource('/api/execute/events');
esRef.current = es;
es.addEventListener('job_update', (e) => {
const d = JSON.parse(e.data) as { id: number; status: string; output?: string };
setData((prev) => {
if (!prev) return prev;
return {
...prev,
jobs: prev.jobs.map((j) =>
j.job.id === d.id ? { ...j, job: { ...j.job, status: d.status as Job['status'] } } : j
),
};
});
if (d.output !== undefined) {
setLogs((prev) => { const m = new Map(prev); m.set(d.id, d.output!); return m; });
}
// Reload row on terminal state to get accurate data
if (d.status === 'done' || d.status === 'error') {
api.get<{ job: Job; item: MediaItem | null; node: Node | null; nodes: Node[] }>(`/api/execute/job/${d.id}/run`).catch(() => {});
}
});
return () => es.close();
}, []);
const startAll = async () => { await api.post('/api/execute/start'); load(); };
const runJob = async (id: number) => { await api.post(`/api/execute/job/${id}/run`); load(); };
const cancelJob = async (id: number) => { await api.post(`/api/execute/job/${id}/cancel`); load(); };
const assignNode = async (jobId: number, nodeId: number | null) => {
await api.post(`/api/execute/job/${jobId}/assign`, { node_id: nodeId });
load();
};
const toggleLog = (id: number) => setLogVisible((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
if (!data) return <div className="text-gray-400 py-8 text-center">Loading</div>;
const { jobs, nodes } = data;
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;
return (
<div>
<div className="flex items-center gap-3 mb-4">
<h1 className="text-xl font-bold m-0">Execute Jobs</h1>
</div>
<div className="flex items-center gap-3 mb-6 flex-wrap">
<Badge variant="pending">{pending} pending</Badge>
{running > 0 && <Badge variant="running">{running} running</Badge>}
{done > 0 && <Badge variant="done">{done} done</Badge>}
{errors > 0 && <Badge variant="error">{errors} error(s)</Badge>}
</div>
<div className="flex gap-3 mb-6">
{pending > 0 && <Button onClick={startAll}> Run all pending</Button>}
{jobs.length === 0 && (
<p className="text-gray-500 m-0">
No jobs yet. Go to <Link to="/review">Review</Link> and approve items first.
</p>
)}
</div>
{jobs.length > 0 && (
<table className="w-full border-collapse text-[0.82rem]">
<thead>
<tr>
{['#', 'Item', 'Command', 'Node', 'Status', 'Actions'].map((h) => (
<th key={h} className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{jobs.map(({ job, item, node }) => {
const name = itemName(job, item);
const cmdShort = job.command.length > 80 ? job.command.slice(0, 77) + '…' : job.command;
const jobLog = logs.get(job.id) ?? job.output ?? '';
const showLog = logVisible.has(job.id) || job.status === 'running' || job.status === 'error';
return (
<>
<tr key={job.id} className="hover:bg-gray-50">
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{job.id}</td>
<td className="py-1.5 px-2 border-b border-gray-100">
<div className="truncate max-w-[200px]" title={name}>{name}</div>
{item && (
<div className="text-[0.72rem] mt-0.5">
<Link to="/review/$id" params={{ id: String(item.id) }} className="text-gray-400 no-underline hover:underline"> Details</Link>
</div>
)}
{item?.file_path && (
<div className="font-mono text-gray-400 text-[0.72rem] truncate max-w-[200px] mt-0.5" title={item.file_path}>
{item.file_path.split('/').pop()}
</div>
)}
</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-[0.75rem] max-w-[300px]">
<span title={job.command}>{cmdShort}</span>
</td>
<td className="py-1.5 px-2 border-b border-gray-100">
{job.status === 'pending' ? (
<Select
value={node?.id ?? ''}
onChange={(e) => assignNode(job.id, e.target.value ? Number(e.target.value) : null)}
className="text-[0.8rem] py-0.5 px-1 w-auto"
>
<option value="">Local</option>
{nodes.map((n) => <option key={n.id} value={n.id}>{n.name} ({n.host})</option>)}
</Select>
) : (
<span className="text-gray-500">{node?.name ?? 'Local'}</span>
)}
</td>
<td className="py-1.5 px-2 border-b border-gray-100">
<Badge variant={job.status}>{job.status}</Badge>
{job.exit_code != null && job.exit_code !== 0 && <Badge variant="error" className="ml-1">exit {job.exit_code}</Badge>}
</td>
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap">
<div className="flex gap-1 items-center">
{job.status === 'pending' && (
<>
<Button size="sm" onClick={() => runJob(job.id)}> Run</Button>
<Button size="sm" variant="secondary" onClick={() => cancelJob(job.id)}></Button>
</>
)}
{(job.status === 'done' || job.status === 'error') && jobLog && (
<Button size="sm" variant="secondary" onClick={() => toggleLog(job.id)}>Log</Button>
)}
</div>
</td>
</tr>
{jobLog && (
<tr key={`log-${job.id}`}>
<td colSpan={6} className="p-0 border-b border-gray-100">
{showLog && (
<div className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#d4d4d4] px-3.5 py-2.5 rounded max-h-[260px] overflow-y-auto whitespace-pre-wrap break-all">
{jobLog}
</div>
)}
</td>
</tr>
)}
</>
);
})}
</tbody>
</table>
)}
</div>
);
}

View File

@@ -0,0 +1,146 @@
import { useEffect, useRef, useState } from 'react';
import { api } from '~/shared/lib/api';
import { Badge } from '~/shared/components/ui/badge';
import { Button } from '~/shared/components/ui/button';
import { Input } from '~/shared/components/ui/input';
import { Alert } from '~/shared/components/ui/alert';
import type { Node } from '~/shared/lib/types';
interface NodesData { nodes: Node[]; }
function nodeStatusVariant(status: string): 'done' | 'error' | 'pending' {
if (status === 'ok') return 'done';
if (status.startsWith('error')) return 'error';
return 'pending';
}
export function NodesPage() {
const [nodes, setNodes] = useState<Node[]>([]);
const [error, setError] = useState('');
const [testing, setTesting] = useState<Set<number>>(new Set());
const fileRef = useRef<HTMLInputElement>(null);
const load = () => api.get<NodesData>('/api/nodes').then((d) => setNodes(d.nodes));
useEffect(() => { load(); }, []);
const submit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError('');
const form = e.currentTarget;
const fd = new FormData(form);
const result = await api.postForm<NodesData>('/api/nodes', fd).catch((err) => { setError(String(err)); return null; });
if (result) { setNodes(result.nodes); form.reset(); if (fileRef.current) fileRef.current.value = ''; }
};
const deleteNode = async (id: number) => {
if (!confirm('Remove node?')) return;
await api.post(`/api/nodes/${id}/delete`);
load();
};
const testNode = async (id: number) => {
setTesting((s) => { const n = new Set(s); n.add(id); return n; });
await api.post<{ ok: boolean; status: string }>(`/api/nodes/${id}/test`);
setTesting((s) => { const n = new Set(s); n.delete(id); return n; });
load();
};
return (
<div>
<div className="flex items-center gap-3 mb-4">
<h1 className="text-xl font-bold m-0">Remote Nodes</h1>
</div>
<p className="text-gray-500 mb-4">
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 form */}
<div className="border border-gray-200 rounded-lg p-4 mb-4">
<div className="font-semibold text-sm mb-3">Add Node</div>
{error && <Alert variant="error" className="mb-3">{error}</Alert>}
<form onSubmit={submit}>
<div className="grid grid-cols-2 gap-4 mb-3">
<label className="block text-sm text-gray-700 mb-0.5">
Name
<Input name="name" placeholder="my-server" required className="mt-0.5" />
</label>
<label className="block text-sm text-gray-700 mb-0.5">
Host
<Input name="host" placeholder="192.168.1.200" required className="mt-0.5" />
</label>
<label className="block text-sm text-gray-700 mb-0.5">
SSH Port
<Input type="number" name="port" defaultValue="22" min="1" max="65535" className="mt-0.5" />
</label>
<label className="block text-sm text-gray-700 mb-0.5">
Username
<Input name="username" placeholder="root" required className="mt-0.5" />
</label>
<label className="block text-sm text-gray-700 mb-0.5">
FFmpeg path
<Input name="ffmpeg_path" defaultValue="ffmpeg" className="mt-0.5" />
</label>
<label className="block text-sm text-gray-700 mb-0.5">
Work directory
<Input name="work_dir" defaultValue="/tmp" className="mt-0.5" />
</label>
</div>
<label className="block text-sm text-gray-700 mb-0.5">
Private key (PEM)
<input
ref={fileRef}
type="file"
name="private_key"
accept=".pem,.key,text/plain"
required
className="border border-gray-300 rounded px-2.5 py-1.5 text-sm bg-white w-full mt-0.5"
/>
<small className="text-xs text-gray-500 mt-0.5 block">Upload your SSH private key file. Stored securely in the database.</small>
</label>
<Button type="submit" className="mt-3">Add Node</Button>
</form>
</div>
{/* Node list */}
{nodes.length === 0 ? (
<p className="text-gray-500">No nodes configured. Add one above.</p>
) : (
<table className="w-full border-collapse text-[0.82rem]">
<thead>
<tr>
{['Name', 'Host', 'Port', 'User', 'FFmpeg', 'Status', 'Actions'].map((h) => (
<th key={h} className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{nodes.map((node) => (
<tr key={node.id} className="hover:bg-gray-50">
<td className="py-1.5 px-2 border-b border-gray-100"><strong>{node.name}</strong></td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{node.host}</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{node.port}</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{node.username}</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{node.ffmpeg_path}</td>
<td className="py-1.5 px-2 border-b border-gray-100">
<Badge variant={nodeStatusVariant(node.status)}>{node.status}</Badge>
</td>
<td className="py-1.5 px-2 border-b border-gray-100">
<div className="flex gap-1 items-center">
<Button size="sm" onClick={() => testNode(node.id)} disabled={testing.has(node.id)}>
{testing.has(node.id) ? '…' : 'Test'}
</Button>
<Button size="sm" variant="secondary" onClick={() => deleteNode(node.id)}>Remove</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
import type React from 'react';

View File

@@ -0,0 +1,334 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from '@tanstack/react-router';
import { api } from '~/shared/lib/api';
import { Badge } from '~/shared/components/ui/badge';
import { Button } from '~/shared/components/ui/button';
import { Alert } from '~/shared/components/ui/alert';
import { Select } from '~/shared/components/ui/select';
import { langName, LANG_NAMES } from '~/shared/lib/lang';
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '~/shared/lib/types';
// ─── Types ────────────────────────────────────────────────────────────────────
interface DetailData {
item: MediaItem; streams: MediaStream[];
plan: ReviewPlan | null; decisions: StreamDecision[];
command: string | null; dockerCommand: string | null; dockerMountDir: string | null;
}
// ─── Utilities ────────────────────────────────────────────────────────────────
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`;
}
function effectiveLabel(s: MediaStream, dec: StreamDecision | undefined): string {
if (dec?.custom_title) return dec.custom_title;
if (s.type === 'Subtitle') {
if (!s.language) return '';
const base = langName(s.language);
if (s.is_forced) return `${base} (Forced)`;
if (s.is_hearing_impaired) return `${base} (CC)`;
return base;
}
if (s.title) return s.title;
if (s.type === 'Audio' && s.channels) return `${s.channels}ch ${s.channel_layout ?? ''}`.trim();
return s.language ? langName(s.language) : '';
}
// ─── Stream table ─────────────────────────────────────────────────────────────
const STREAM_SECTIONS = [
{ type: 'Video', label: 'Video' },
{ type: 'Audio', label: 'Audio' },
{ type: 'Subtitle', label: 'Subtitles — all extracted to sidecar files' },
{ type: 'Data', label: 'Data' },
{ type: 'EmbeddedImage', label: 'Embedded Images' },
];
const TYPE_ORDER: Record<string, number> = { Video: 0, Audio: 1, Subtitle: 2, Data: 3, EmbeddedImage: 4 };
function computeOutIdx(streams: MediaStream[], decisions: StreamDecision[]): Map<number, number> {
const mappedKept = streams
.filter((s) => ['Video', 'Audio'].includes(s.type))
.filter((s) => {
const action = decisions.find((d) => d.stream_id === s.id)?.action;
return action === 'keep';
})
.sort((a, b) => {
const ta = TYPE_ORDER[a.type] ?? 9;
const tb = TYPE_ORDER[b.type] ?? 9;
if (ta !== tb) return ta - tb;
const da = decisions.find((d) => d.stream_id === a.id);
const db = decisions.find((d) => d.stream_id === b.id);
return (da?.target_index ?? 0) - (db?.target_index ?? 0);
});
const m = new Map<number, number>();
mappedKept.forEach((s, i) => m.set(s.id, i));
return m;
}
interface StreamTableProps { data: DetailData; onUpdate: (d: DetailData) => void; }
function StreamTable({ data, onUpdate }: StreamTableProps) {
const { item, streams, plan, decisions } = data;
const outIdx = computeOutIdx(streams, decisions);
const toggleStream = async (streamId: number, currentAction: 'keep' | 'remove') => {
const d = await api.patch<DetailData>(`/api/review/${item.id}/stream/${streamId}`, { action: currentAction === 'keep' ? 'remove' : 'keep' });
onUpdate(d);
};
const updateTitle = async (streamId: number, title: string) => {
const d = await api.patch<DetailData>(`/api/review/${item.id}/stream/${streamId}/title`, { title });
onUpdate(d);
};
return (
<table className="w-full border-collapse text-[0.79rem] mt-1">
<thead>
<tr>
{['Out', 'Codec', 'Language', 'Title / Info', 'Flags', 'Action'].map((h) => (
<th key={h} className="text-left text-[0.67rem] uppercase tracking-[0.05em] text-gray-500 py-1 px-2 border-b border-gray-200">{h}</th>
))}
</tr>
</thead>
<tbody>
{STREAM_SECTIONS.flatMap(({ type, label }) => {
const group = streams.filter((s) => s.type === type);
if (group.length === 0) return [];
return [
<tr key={`hdr-${type}`}>
<td colSpan={6} className="text-[0.67rem] font-bold uppercase tracking-[0.06em] text-gray-500 bg-gray-50 py-0.5 px-2 border-b border-gray-100">
{label}
</td>
</tr>,
...group.map((s) => {
const dec = decisions.find((d) => d.stream_id === s.id);
const action = dec?.action ?? 'keep';
const isSub = s.type === 'Subtitle';
const isAudio = s.type === 'Audio';
const outputNum = outIdx.get(s.id);
const lbl = effectiveLabel(s, dec);
const origTitle = s.title;
const lang = langName(s.language);
// Only audio streams can be edited; subtitles are always extracted
const isEditable = plan?.status === 'pending' && isAudio;
const rowBg = isSub ? 'bg-sky-50' : action === 'keep' ? 'bg-green-50' : 'bg-red-50';
return (
<tr key={s.id} className={rowBg}>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">
{isSub ? <span className="text-gray-400"></span> : outputNum !== undefined ? outputNum : <span className="text-gray-400"></span>}
</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.codec ?? '—'}</td>
<td className="py-1.5 px-2 border-b border-gray-100">
{lang} {s.language ? <span className="text-gray-400 font-mono text-xs">({s.language})</span> : null}
</td>
<td className="py-1.5 px-2 border-b border-gray-100">
{isEditable ? (
<TitleInput
value={lbl}
onCommit={(v) => updateTitle(s.id, v)}
/>
) : (
<span>{lbl || '—'}</span>
)}
{isEditable && origTitle && origTitle !== lbl && (
<div className="text-gray-400 text-[0.7rem] mt-0.5">orig: {origTitle}</div>
)}
</td>
<td className="py-1.5 px-2 border-b border-gray-100">
<span className="inline-flex gap-1">
{s.is_default ? <Badge>default</Badge> : null}
{s.is_forced ? <Badge variant="manual">forced</Badge> : null}
{s.is_hearing_impaired ? <Badge>CC</Badge> : null}
</span>
</td>
<td className="py-1.5 px-2 border-b border-gray-100">
{isSub ? (
<span className="inline-block border-0 rounded px-2 py-0.5 text-[0.72rem] font-semibold bg-sky-600 text-white min-w-[4.5rem]">
Extract
</span>
) : plan?.status === 'pending' && (isAudio) ? (
<button
type="button"
onClick={() => toggleStream(s.id, action)}
className={`border-0 rounded px-2 py-0.5 text-[0.72rem] font-semibold cursor-pointer min-w-[4.5rem] ${action === 'keep' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'}`}
>
{action === 'keep' ? '✓ Keep' : '✗ Remove'}
</button>
) : (
<Badge variant={action === 'keep' ? 'keep' : 'remove'}>{action}</Badge>
)}
</td>
</tr>
);
}),
];
})}
</tbody>
</table>
);
}
function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
const [localVal, setLocalVal] = useState(value);
useEffect(() => { setLocalVal(value); }, [value]);
return (
<input
type="text"
value={localVal}
onChange={(e) => setLocalVal(e.target.value)}
onBlur={(e) => { if (e.target.value !== value) onCommit(e.target.value); }}
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
placeholder="—"
className="border border-transparent bg-transparent w-full text-[inherit] font-[inherit] text-inherit px-[0.2em] py-[0.05em] rounded outline-none hover:border-gray-300 hover:bg-gray-50 focus:border-blue-300 focus:bg-white min-w-16"
/>
);
}
// ─── Detail page ──────────────────────────────────────────────────────────────
export function AudioDetailPage() {
const { id } = useParams({ from: '/review/audio/$id' });
const [data, setData] = useState<DetailData | null>(null);
const [loading, setLoading] = useState(true);
const [rescanning, setRescanning] = useState(false);
const load = () => api.get<DetailData>(`/api/review/${id}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
useEffect(() => { load(); }, [id]);
const setLanguage = async (lang: string) => {
const d = await api.patch<DetailData>(`/api/review/${id}/language`, { language: lang || null });
setData(d);
};
const approve = async () => { await api.post(`/api/review/${id}/approve`); load(); };
const skip = async () => { await api.post(`/api/review/${id}/skip`); load(); };
const unskip = async () => { await api.post(`/api/review/${id}/unskip`); load(); };
const rescan = async () => {
setRescanning(true);
try { const d = await api.post<DetailData>(`/api/review/${id}/rescan`); setData(d); }
finally { setRescanning(false); }
};
if (loading) return <div className="text-gray-400 py-8 text-center">Loading</div>;
if (!data) return <Alert variant="error">Item not found.</Alert>;
const { item, plan, command, dockerCommand, dockerMountDir } = data;
const statusKey = plan?.is_noop ? 'noop' : (plan?.status ?? 'pending');
return (
<div>
<div className="flex items-center gap-2 mb-4">
<h1 className="text-xl font-bold m-0">
<Link to="/review/audio" className="font-normal mr-2 no-underline text-gray-500 hover:text-gray-700"> Audio</Link>
{item.name}
</h1>
</div>
<div className="border border-gray-200 rounded-lg p-4 mb-3">
{/* Meta */}
<dl className="flex flex-wrap gap-5 mb-3 text-[0.82rem]">
{[
{ label: 'Type', value: item.type },
...(item.series_name ? [{ label: 'Series', value: item.series_name }] : []),
...(item.year ? [{ label: 'Year', value: String(item.year) }] : []),
{ label: 'Container', value: item.container ?? '—' },
{ label: 'File size', value: item.file_size ? formatBytes(item.file_size) : '—' },
{ label: 'Status', value: <Badge variant={statusKey as 'noop' | 'pending' | 'approved' | 'skipped' | 'done' | 'error'}>{statusKey}</Badge> },
].map((entry, i) => (
<div key={i}>
<dt className="text-gray-500 text-[0.68rem] uppercase tracking-[0.05em] mb-0.5">{entry.label}</dt>
<dd className="m-0 font-medium">{entry.value}</dd>
</div>
))}
</dl>
<div className="font-mono text-gray-400 text-[0.78rem] mb-4 break-all">{item.file_path}</div>
{/* Warnings */}
{plan?.notes && <Alert variant="warning" className="mb-3">{plan.notes}</Alert>}
{item.needs_review && !item.original_language && (
<Alert variant="warning" className="mb-3">
Original language unknown audio tracks will NOT be filtered until you set it below.
</Alert>
)}
{/* Language override */}
<div className="flex items-center gap-2 mb-4">
<label className="text-[0.85rem] m-0">Original language:</label>
<Select value={item.original_language ?? ''} onChange={(e) => setLanguage(e.target.value)} className="text-[0.79rem] py-0.5 px-1.5 w-auto">
<option value=""> Unknown </option>
{Object.entries(LANG_NAMES).map(([code, name]) => (
<option key={code} value={code}>{name} ({code})</option>
))}
</Select>
{item.orig_lang_source && <Badge>{item.orig_lang_source}</Badge>}
</div>
{/* Stream table */}
<StreamTable data={data} onUpdate={setData} />
{/* FFmpeg command */}
{command && (
<div className="mt-6">
<div className="text-gray-400 text-[0.75rem] uppercase tracking-[0.05em] mb-1">FFmpeg command (audio + subtitle extraction)</div>
<textarea
readOnly
rows={3}
value={command}
className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#9cdcfe] p-3 rounded w-full resize-y border-0 min-h-10"
/>
</div>
)}
{dockerCommand && (
<div className="mt-3">
<div className="flex items-baseline gap-2 mb-1 flex-wrap">
<span className="text-gray-400 text-[0.75rem] uppercase tracking-[0.05em]">Docker (fallback)</span>
<span className="text-gray-400 text-[0.7rem]"> mount: <code className="font-mono">{dockerMountDir}:/work</code></span>
</div>
<textarea
readOnly
rows={3}
value={dockerCommand}
className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#9cdcfe] p-3 rounded w-full resize-y border-0 min-h-10"
/>
</div>
)}
{/* Actions */}
{plan?.status === 'pending' && !plan.is_noop && (
<div className="flex gap-2 mt-6">
<Button onClick={approve}> Approve</Button>
<Button variant="secondary" onClick={skip}>Skip</Button>
</div>
)}
{plan?.status === 'skipped' && (
<div className="mt-6">
<Button variant="secondary" onClick={unskip}>Unskip</Button>
</div>
)}
{plan?.is_noop ? (
<Alert variant="success" className="mt-4">Audio is already clean no audio changes needed.</Alert>
) : null}
{/* Refresh */}
<div className="flex items-center gap-3 mt-6 pt-3 border-t border-gray-200">
<Button variant="secondary" size="sm" onClick={rescan} disabled={rescanning}>
{rescanning ? '↻ Refreshing…' : '↻ Refresh from Jellyfin'}
</Button>
<span className="text-gray-400 text-[0.75rem]">
{rescanning ? 'Triggering Jellyfin metadata probe and waiting for completion…' : 'Triggers a metadata re-probe in Jellyfin, then re-fetches stream data'}
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,300 @@
import { useState, useEffect } from 'react';
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
import { api } from '~/shared/lib/api';
import { Badge } from '~/shared/components/ui/badge';
import { Button } from '~/shared/components/ui/button';
import { langName } from '~/shared/lib/lang';
import type { MediaItem, ReviewPlan } from '~/shared/lib/types';
// ─── Types ────────────────────────────────────────────────────────────────────
interface MovieRow { item: MediaItem; plan: ReviewPlan | null; removeCount: number; keepCount: number; }
interface SeriesGroup {
series_key: string; series_name: string; original_language: string | null;
season_count: number; episode_count: number;
noop_count: number; needs_action_count: number; approved_count: number;
skipped_count: number; done_count: number; error_count: number; manual_count: number;
}
interface ReviewListData {
movies: MovieRow[];
series: SeriesGroup[];
filter: string;
totalCounts: Record<string, number>;
}
// ─── Filter tabs ──────────────────────────────────────────────────────────────
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' },
];
// ─── Status pills ─────────────────────────────────────────────────────────────
function StatusPills({ g }: { g: SeriesGroup }) {
return (
<span className="inline-flex flex-wrap gap-1 items-center">
{g.noop_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">{g.noop_count} ok</span>}
{g.needs_action_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-800">{g.needs_action_count} action</span>}
{g.approved_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-green-100 text-green-800">{g.approved_count} approved</span>}
{g.done_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-cyan-100 text-cyan-800">{g.done_count} done</span>}
{g.error_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-800">{g.error_count} err</span>}
{g.skipped_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">{g.skipped_count} skip</span>}
{g.manual_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-orange-100 text-orange-800">{g.manual_count} manual</span>}
</span>
);
}
// ─── Th helper ───────────────────────────────────────────────────────────────
const Th = ({ children }: { children: React.ReactNode }) => (
<th className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">
{children}
</th>
);
const Td = ({ children, className }: { children?: React.ReactNode; className?: string }) => (
<td className={`py-1.5 px-2 border-b border-gray-100 align-middle ${className ?? ''}`}>{children}</td>
);
// ─── Series row (collapsible) ─────────────────────────────────────────────────
function SeriesRow({ g }: { g: SeriesGroup }) {
const [open, setOpen] = useState(false);
const urlKey = encodeURIComponent(g.series_key);
interface EpisodeItem { item: MediaItem; plan: ReviewPlan | null; removeCount: number; }
interface SeasonGroup { season: number | null; episodes: EpisodeItem[]; noopCount: number; actionCount: number; approvedCount: number; doneCount: number; }
const [seasons, setSeasons] = useState<SeasonGroup[] | null>(null);
const toggle = async () => {
if (!open && seasons === null) {
const data = await api.get<{ seasons: SeasonGroup[] }>(`/api/review/series/${urlKey}/episodes`);
setSeasons(data.seasons);
}
setOpen((v) => !v);
};
const approveAll = async (e: React.MouseEvent) => {
e.stopPropagation();
await api.post(`/api/review/series/${urlKey}/approve-all`);
window.location.reload();
};
const approveSeason = async (e: React.MouseEvent, season: number | null) => {
e.stopPropagation();
await api.post(`/api/review/season/${urlKey}/${season ?? 0}/approve-all`);
window.location.reload();
};
const statusKey = (plan: ReviewPlan | null) => plan?.is_noop ? 'noop' : (plan?.status ?? 'pending');
return (
<tbody>
<tr
className="cursor-pointer hover:bg-gray-50"
onClick={toggle}
>
<td className="py-1.5 px-2 border-b border-gray-100 font-medium">
<span className={`inline-flex items-center justify-center w-4 h-4 rounded text-[0.65rem] text-gray-500 transition-transform ${open ? 'rotate-90' : ''}`}></span>
{' '}<strong>{g.series_name}</strong>
</td>
<Td>{langName(g.original_language)}</Td>
<td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.season_count}</td>
<td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.episode_count}</td>
<Td><StatusPills g={g} /></Td>
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
{g.needs_action_count > 0 && (
<Button size="xs" onClick={approveAll}>Approve all</Button>
)}
</td>
</tr>
{open && seasons && (
<tr>
<td colSpan={6} className="p-0 border-b border-gray-100">
<table className="w-full border-collapse">
<tbody>
{seasons.map((s) => (
<>
<tr key={`season-${s.season}`} className="bg-gray-50">
<td colSpan={4} className="text-[0.7rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-0.5 px-8 border-b border-gray-100">
Season {s.season ?? '?'}
<span className="ml-3 inline-flex gap-1">
{s.noopCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-gray-200 text-gray-600 text-[0.7rem]">{s.noopCount} ok</span>}
{s.actionCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-red-100 text-red-800 text-[0.7rem]">{s.actionCount} action</span>}
{s.approvedCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-800 text-[0.7rem]">{s.approvedCount} approved</span>}
{s.doneCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-cyan-100 text-cyan-800 text-[0.7rem]">{s.doneCount} done</span>}
</span>
{s.actionCount > 0 && (
<Button size="xs" variant="secondary" className="ml-3" onClick={(e) => approveSeason(e, s.season)}>
Approve season
</Button>
)}
</td>
</tr>
{s.episodes.map(({ item, plan, removeCount }) => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem] pl-10">
<span className="text-gray-400 font-mono text-xs">E{String(item.episode_number ?? 0).padStart(2, '0')}</span>
{' '}
<span className="truncate inline-block max-w-xs align-bottom">{item.name}</span>
</td>
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem]">
{removeCount > 0 ? <Badge variant="remove">{removeCount}</Badge> : <span className="text-gray-400"></span>}
</td>
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem]">
<Badge variant={statusKey(plan) as 'noop' | 'pending' | 'approved' | 'skipped' | 'done' | 'error'}>{plan?.is_noop ? 'ok' : (plan?.status ?? 'pending')}</Badge>
</td>
<td className="py-1 px-2 border-b border-gray-100 whitespace-nowrap flex gap-1 items-center">
{plan?.status === 'pending' && !plan.is_noop && (
<ApproveBtn itemId={item.id} size="xs" />
)}
{plan?.status === 'pending' && (
<SkipBtn itemId={item.id} size="xs" />
)}
{plan?.status === 'skipped' && (
<UnskipBtn itemId={item.id} size="xs" />
)}
<Link to="/review/audio/$id" params={{ id: String(item.id) }} className="inline-flex items-center justify-center gap-1 rounded px-2 py-0.5 text-xs font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline">
Detail
</Link>
</td>
</tr>
))}
</>
))}
</tbody>
</table>
</td>
</tr>
)}
</tbody>
);
}
// ─── Action buttons ───────────────────────────────────────────────────────────
function ApproveBtn({ itemId, size }: { itemId: number; size?: 'xs' | 'sm' }) {
const onClick = async () => { await api.post(`/api/review/${itemId}/approve`); window.location.reload(); };
return <Button size={size ?? 'xs'} onClick={onClick}>Approve</Button>;
}
function SkipBtn({ itemId, size }: { itemId: number; size?: 'xs' | 'sm' }) {
const onClick = async () => { await api.post(`/api/review/${itemId}/skip`); window.location.reload(); };
return <Button size={size ?? 'xs'} variant="secondary" onClick={onClick}>Skip</Button>;
}
function UnskipBtn({ itemId, size }: { itemId: number; size?: 'xs' | 'sm' }) {
const onClick = async () => { await api.post(`/api/review/${itemId}/unskip`); window.location.reload(); };
return <Button size={size ?? 'xs'} variant="secondary" onClick={onClick}>Unskip</Button>;
}
// ─── Main page ────────────────────────────────────────────────────────────────
export function AudioListPage() {
const { filter } = useSearch({ from: '/review/audio/' });
const navigate = useNavigate();
const [data, setData] = useState<ReviewListData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get<ReviewListData>(`/api/review?filter=${filter}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
}, [filter]);
const approveAll = async () => {
await api.post('/api/review/approve-all');
window.location.reload();
};
if (loading) return <div className="text-gray-400 py-8 text-center">Loading</div>;
if (!data) return <div className="text-red-600">Failed to load.</div>;
const { movies, series, totalCounts } = data;
const hasPending = (totalCounts.needs_action ?? 0) > 0;
const statusKey = (plan: ReviewPlan | null) => plan?.is_noop ? 'noop' : (plan?.status ?? 'pending');
return (
<div>
<div className="flex items-center gap-3 mb-4">
<h1 className="text-xl font-bold m-0">Audio Review</h1>
{hasPending && <Button size="sm" onClick={approveAll}>Approve all pending</Button>}
</div>
{/* Filter tabs */}
<div className="flex gap-1 flex-wrap mb-3 items-center">
{FILTER_TABS.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => navigate({ to: '/review/audio', search: { filter: tab.key } as never })}
className={`px-2.5 py-0.5 rounded text-[0.8rem] border cursor-pointer transition-colors leading-[1.4] ${filter === tab.key ? 'bg-blue-600 border-blue-600 text-white' : 'border-gray-200 bg-transparent text-gray-500 hover:bg-gray-50'}`}
>
{tab.label}
{totalCounts[tab.key] != null && <> <span className="text-[0.72rem] font-bold">{totalCounts[tab.key]}</span></>}
</button>
))}
</div>
{movies.length === 0 && series.length === 0 && (
<p className="text-gray-500">No items match this filter.</p>
)}
{/* Movies */}
{movies.length > 0 && (
<>
<div className="flex items-center gap-2 mt-5 mb-1.5 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-gray-500">
Movies <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{movies.length}</span>
</div>
<table className="w-full border-collapse text-[0.82rem]">
<thead><tr><Th>Name</Th><Th>Lang</Th><Th>Remove</Th><Th>Status</Th><Th>Actions</Th></tr></thead>
<tbody>
{movies.map(({ item, plan, removeCount }) => (
<tr key={item.id} className="hover:bg-gray-50">
<Td>
<span className="truncate inline-block max-w-[360px]" title={item.name}>{item.name}</span>
{item.year && <span className="text-gray-400 text-[0.72rem]"> ({item.year})</span>}
</Td>
<Td>
{item.needs_review && !item.original_language
? <Badge variant="manual">manual</Badge>
: <span>{langName(item.original_language)}</span>}
</Td>
<Td>{removeCount > 0 ? <Badge variant="remove">{removeCount}</Badge> : <span className="text-gray-400"></span>}</Td>
<Td><Badge variant={statusKey(plan) as 'noop' | 'pending' | 'approved' | 'skipped' | 'done' | 'error'}>{plan?.is_noop ? 'ok' : (plan?.status ?? 'pending')}</Badge></Td>
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap flex gap-1 items-center">
{plan?.status === 'pending' && !plan.is_noop && <ApproveBtn itemId={item.id} />}
{plan?.status === 'pending' && <SkipBtn itemId={item.id} />}
{plan?.status === 'skipped' && <UnskipBtn itemId={item.id} />}
<Link to="/review/audio/$id" params={{ id: String(item.id) }} className="inline-flex items-center justify-center gap-1 rounded px-2 py-0.5 text-xs font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline">
Detail
</Link>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
{/* TV Series */}
{series.length > 0 && (
<>
<div className={`flex items-center gap-2 mb-1.5 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-gray-500 ${movies.length > 0 ? 'mt-5' : 'mt-0'}`}>
TV Series <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{series.length}</span>
</div>
<table className="w-full border-collapse text-[0.82rem]">
<thead><tr><Th>Series</Th><Th>Lang</Th><Th>S</Th><Th>Ep</Th><Th>Status</Th><Th>Actions</Th></tr></thead>
{series.map((g) => <SeriesRow key={g.series_key} g={g} />)}
</table>
</>
)}
</div>
);
}
import type React from 'react';

View File

@@ -0,0 +1,155 @@
import { useEffect, useRef, useState } from 'react';
import { api } from '~/shared/lib/api';
import { Button } from '~/shared/components/ui/button';
import { Badge } from '~/shared/components/ui/badge';
interface Progress { scanned: number; total: number; errors: number; }
interface ScanStatus { running: boolean; progress: Progress; recentItems: { name: string; type: string; scan_status: string }[]; scanLimit: number | null; }
interface LogEntry { name: string; type: string; status: string; }
export function ScanPage() {
const [status, setStatus] = useState<ScanStatus | null>(null);
const [limit, setLimit] = useState('');
const [log, setLog] = useState<LogEntry[]>([]);
const [statusLabel, setStatusLabel] = useState('');
const [currentItem, setCurrentItem] = useState('');
const [progressScanned, setProgressScanned] = useState(0);
const [progressTotal, setProgressTotal] = useState(0);
const [errors, setErrors] = useState(0);
const esRef = useRef<EventSource | null>(null);
const load = async () => {
const s = await api.get<ScanStatus>('/api/scan');
setStatus(s);
setProgressScanned(s.progress.scanned);
setProgressTotal(s.progress.total);
setErrors(s.progress.errors);
setStatusLabel(s.running ? 'Scan in progress…' : 'Scan idle');
if (s.scanLimit != null) setLimit(String(s.scanLimit));
setLog(s.recentItems.map((i) => ({ name: i.name, type: i.type, status: i.scan_status })));
};
useEffect(() => { load(); }, []);
// Connect SSE when running
useEffect(() => {
if (!status?.running) return;
const es = new EventSource('/api/scan/events');
esRef.current = es;
es.addEventListener('progress', (e) => {
const d = JSON.parse(e.data) as { scanned: number; total: number; errors: number; current_item: string };
setProgressScanned(d.scanned);
setProgressTotal(d.total);
setErrors(d.errors);
setCurrentItem(d.current_item ?? '');
});
es.addEventListener('log', (e) => {
const d = JSON.parse(e.data) as LogEntry;
setLog((prev) => [d, ...prev].slice(0, 100));
});
es.addEventListener('complete', (e) => {
const d = JSON.parse(e.data || '{}') as { scanned?: number; errors?: number };
es.close();
setStatusLabel(`Scan complete — ${d.scanned ?? '?'} items, ${d.errors ?? 0} errors`);
setStatus((prev) => prev ? { ...prev, running: false } : prev);
});
es.addEventListener('error', () => {
es.close();
setStatusLabel('Scan connection lost — refresh to see current status');
});
return () => es.close();
}, [status?.running]);
const startScan = async () => {
const limitNum = limit ? Number(limit) : undefined;
await api.post('/api/scan/start', limitNum !== undefined ? { limit: limitNum } : {});
setStatus((prev) => prev ? { ...prev, running: true } : prev);
setStatusLabel('Scan in progress…');
setLog([]);
};
const stopScan = async () => {
await api.post('/api/scan/stop', {});
esRef.current?.close();
setStatus((prev) => prev ? { ...prev, running: false } : prev);
setStatusLabel('Scan stopped');
};
const pct = progressTotal > 0 ? Math.round((progressScanned / progressTotal) * 100) : 0;
const running = status?.running ?? false;
return (
<div>
<div className="flex items-center gap-3 mb-4">
<h1 className="text-xl font-bold m-0">Library Scan</h1>
</div>
{/* Status card */}
<div className="border border-gray-200 rounded-lg p-4 mb-6">
<div className="flex items-center flex-wrap gap-2 mb-4">
<strong>{statusLabel || (running ? 'Scan in progress…' : 'Scan idle')}</strong>
{running ? (
<Button variant="secondary" size="sm" onClick={stopScan}>Stop</Button>
) : (
<div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 text-xs m-0">
Limit
<input
type="number"
value={limit}
onChange={(e) => setLimit(e.target.value)}
placeholder="all"
min="1"
className="border border-gray-300 rounded px-1.5 py-0.5 text-xs w-16"
/>
items
</label>
<Button size="sm" onClick={startScan}>Start Scan</Button>
</div>
)}
{errors > 0 && <Badge variant="error">{errors} error(s)</Badge>}
</div>
{(running || progressTotal > 0) && (
<>
<div className="bg-gray-200 rounded-full h-1.5 overflow-hidden my-2">
<div className="h-full bg-blue-600 rounded-full transition-all duration-300" style={{ width: `${pct}%` }} />
</div>
<div className="flex items-center gap-2 text-gray-500 text-xs">
<span>{progressScanned} / {progressTotal}</span>
{currentItem && <span className="truncate max-w-xs text-gray-400">{currentItem}</span>}
</div>
</>
)}
</div>
{/* Log */}
<h3 className="font-semibold text-sm mb-2">Recent items</h3>
<table className="w-full border-collapse text-[0.82rem]">
<thead>
<tr>
{['Type', 'Name', 'Status'].map((h) => (
<th key={h} className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{log.map((item, i) => (
<tr key={i} className="hover:bg-gray-50">
<td className="py-1.5 px-2 border-b border-gray-100">{item.type}</td>
<td className="py-1.5 px-2 border-b border-gray-100">{item.name}</td>
<td className="py-1.5 px-2 border-b border-gray-100">
<Badge variant={item.status as 'error' | 'done' | 'pending'}>{item.status}</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,242 @@
import { useEffect, useState } from 'react';
import { api } from '~/shared/lib/api';
import { Button } from '~/shared/components/ui/button';
import { Input } from '~/shared/components/ui/input';
import { Alert } from '~/shared/components/ui/alert';
interface SetupData { config: Record<string, string>; envLocked: string[]; }
const SUBTITLE_OPTIONS = [
{ 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' },
];
// ─── Locked input ─────────────────────────────────────────────────────────────
function LockedInput({ locked, ...props }: { locked: boolean } & React.InputHTMLAttributes<HTMLInputElement>) {
return (
<div className="relative">
<Input {...props} disabled={locked || props.disabled} className={locked ? 'pr-9' : ''} />
{locked && (
<span
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[0.9rem] opacity-40 pointer-events-none select-none"
title="Set via environment variable — edit your .env file to change this value"
>
🔒
</span>
)}
</div>
);
}
// ─── Env badge ────────────────────────────────────────────────────────────────
function EnvBadge({ envVar, locked }: { envVar: string; locked: boolean }) {
return (
<span
className="inline-flex items-center gap-1 text-[0.67rem] font-semibold px-1.5 py-0.5 rounded bg-gray-100 text-gray-500 border border-gray-200"
title={locked
? `Set via environment variable ${envVar} — edit your .env file to change`
: `Can be set via environment variable ${envVar}`}
>
{locked ? '🔒' : '🔓'} <span className="font-mono">{envVar}</span>
</span>
);
}
// ─── Section card ──────────────────────────────────────────────────────────────
function SectionCard({ title, subtitle, children }: { title: React.ReactNode; subtitle?: React.ReactNode; children: React.ReactNode }) {
return (
<div className="border border-gray-200 rounded-lg p-4 mb-4">
<div className="font-semibold text-sm mb-1">{title}</div>
{subtitle && <p className="text-gray-500 text-sm mb-3 mt-0">{subtitle}</p>}
{children}
</div>
);
}
// ─── Connection section ────────────────────────────────────────────────────────
function ConnSection({
title, subtitle, cfg, locked, urlKey, apiKey: apiKeyProp, urlPlaceholder, onSave,
}: {
title: React.ReactNode; subtitle?: React.ReactNode; cfg: Record<string, string>; locked: Set<string>;
urlKey: string; apiKey: string; urlPlaceholder: string; onSave: (url: string, apiKey: string) => Promise<void>;
}) {
const [url, setUrl] = useState(cfg[urlKey] ?? '');
const [key, setKey] = useState(cfg[apiKeyProp] ?? '');
const [status, setStatus] = useState<{ ok: boolean; error?: string } | null>(null);
const [saving, setSaving] = useState(false);
const save = async () => {
setSaving(true);
setStatus(null);
try { await onSave(url, key); setStatus({ ok: true }); } catch (e) { setStatus({ ok: false, error: String(e) }); }
setSaving(false);
};
return (
<SectionCard title={title} subtitle={subtitle}>
<label className="block text-sm text-gray-700 mb-1">
URL
<LockedInput locked={locked.has(urlKey)} type="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder={urlPlaceholder} className="mt-0.5 max-w-sm" />
</label>
<label className="block text-sm text-gray-700 mb-1 mt-3">
API Key
<LockedInput locked={locked.has(apiKeyProp)} value={key} onChange={(e) => setKey(e.target.value)} placeholder="your-api-key" className="mt-0.5 max-w-xs" />
</label>
<div className="flex items-center gap-2 mt-3">
<Button onClick={save} disabled={saving || (locked.has(urlKey) && locked.has(apiKeyProp))}>
{saving ? 'Saving…' : 'Test & Save'}
</Button>
{status && (
<span className={`text-sm ${status.ok ? 'text-green-700' : 'text-red-600'}`}>
{status.ok ? '✓ Saved' : `${status.error ?? 'Connection failed'}`}
</span>
)}
</div>
</SectionCard>
);
}
// ─── Setup page ───────────────────────────────────────────────────────────────
export function SetupPage() {
const [data, setData] = useState<SetupData | null>(null);
const [clearStatus, setClearStatus] = useState('');
const load = () => api.get<SetupData>('/api/setup').then(setData);
useEffect(() => { load(); }, []);
if (!data) return <div className="text-gray-400 py-8 text-center">Loading</div>;
const { config: cfg, envLocked: envLockedArr } = data;
const locked = new Set(envLockedArr);
const subtitleLangs: string[] = JSON.parse(cfg.subtitle_languages ?? '["eng","deu","spa"]');
const saveJellyfin = (url: string, apiKey: string) =>
api.post('/api/setup/jellyfin', { url, api_key: apiKey });
const saveRadarr = (url: string, apiKey: string) =>
api.post('/api/setup/radarr', { url, api_key: apiKey });
const saveSonarr = (url: string, apiKey: string) =>
api.post('/api/setup/sonarr', { url, api_key: apiKey });
const saveSubtitleLangs = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const langs = fd.getAll('subtitle_lang') as string[];
await api.post('/api/setup/subtitle-languages', { langs });
};
const savePaths = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
await api.post('/api/setup/paths', { movies_path: fd.get('movies_path'), series_path: fd.get('series_path') });
};
const clearScan = async () => {
if (!confirm('Delete all scanned data? This will remove all media items, stream decisions, review plans, and jobs. This cannot be undone.')) return;
await api.post('/api/setup/clear-scan');
setClearStatus('Cleared.');
};
return (
<div>
<div className="flex items-center gap-3 mb-4">
<h1 className="text-xl font-bold m-0">Settings</h1>
</div>
{/* Jellyfin */}
<ConnSection
title={<span className="flex items-center gap-2">Jellyfin <EnvBadge envVar="JELLYFIN_URL" locked={locked.has('jellyfin_url')} /> <EnvBadge envVar="JELLYFIN_API_KEY" locked={locked.has('jellyfin_api_key')} /></span>}
urlKey="jellyfin_url" apiKey="jellyfin_api_key"
urlPlaceholder="http://192.168.1.100:8096" cfg={cfg} locked={locked}
onSave={saveJellyfin}
/>
{/* Radarr */}
<ConnSection
title={<span className="flex items-center gap-2">Radarr <span className="text-gray-400 font-normal">(optional)</span> <EnvBadge envVar="RADARR_URL" locked={locked.has('radarr_url')} /> <EnvBadge envVar="RADARR_API_KEY" locked={locked.has('radarr_api_key')} /></span>}
subtitle="Provides accurate original-language data for movies."
urlKey="radarr_url" apiKey="radarr_api_key"
urlPlaceholder="http://192.168.1.100:7878" cfg={cfg} locked={locked}
onSave={saveRadarr}
/>
{/* Sonarr */}
<ConnSection
title={<span className="flex items-center gap-2">Sonarr <span className="text-gray-400 font-normal">(optional)</span> <EnvBadge envVar="SONARR_URL" locked={locked.has('sonarr_url')} /> <EnvBadge envVar="SONARR_API_KEY" locked={locked.has('sonarr_api_key')} /></span>}
subtitle="Provides original-language data for TV series."
urlKey="sonarr_url" apiKey="sonarr_api_key"
urlPlaceholder="http://192.168.1.100:8989" cfg={cfg} locked={locked}
onSave={saveSonarr}
/>
{/* Media paths */}
<SectionCard title={<span className="flex items-center gap-2">Media Paths <EnvBadge envVar="MOVIES_PATH" locked={locked.has('movies_path')} /> <EnvBadge envVar="SERIES_PATH" locked={locked.has('series_path')} /></span>} subtitle={
<>
Host paths where your media lives on the machine running the Docker command.
Jellyfin always exposes libraries at <code className="font-mono bg-gray-100 px-1 rounded">/movies</code> and <code className="font-mono bg-gray-100 px-1 rounded">/series</code>,
so only the host-side path is needed to generate a correct Docker command.
</>
}>
<form onSubmit={savePaths}>
<label className="block text-sm text-gray-700 mb-1">
Movies root path
<LockedInput locked={locked.has('movies_path')} name="movies_path" defaultValue={cfg.movies_path ?? ''} placeholder="/mnt/user/storage/Movies" className="mt-0.5 max-w-sm" />
<small className="text-xs text-gray-500 mt-0.5 block">Host directory mounted as <code className="font-mono">/movies</code> inside Jellyfin</small>
</label>
<label className="block text-sm text-gray-700 mb-1 mt-3">
Series root path
<LockedInput locked={locked.has('series_path')} name="series_path" defaultValue={cfg.series_path ?? ''} placeholder="/mnt/user/storage/Series" className="mt-0.5 max-w-sm" />
<small className="text-xs text-gray-500 mt-0.5 block">Host directory mounted as <code className="font-mono">/series</code> inside Jellyfin</small>
</label>
<Button type="submit" className="mt-3">Save</Button>
</form>
</SectionCard>
{/* Subtitle languages */}
<SectionCard
title={
<span className="flex items-center gap-2">
Subtitle Languages
<EnvBadge envVar="SUBTITLE_LANGUAGES" locked={locked.has('subtitle_languages')} />
</span>
}
subtitle="Subtitle tracks in these languages are kept inside the container. Forced and CC/SDH tracks are always kept regardless of language. All subtitles are extracted to sidecar files during processing."
>
<form onSubmit={saveSubtitleLangs}>
<fieldset className="border border-gray-200 rounded p-3 mb-3" disabled={locked.has('subtitle_languages')}>
<legend className="text-xs font-semibold text-gray-600 uppercase tracking-wide px-1">Keep subtitles in:</legend>
{SUBTITLE_OPTIONS.map(({ code, label }) => (
<label key={code} className={`flex items-center gap-2 mb-1 text-sm ${locked.has('subtitle_languages') ? 'text-gray-400' : 'text-gray-700'}`}>
<input type="checkbox" name="subtitle_lang" value={code} defaultChecked={subtitleLangs.includes(code)} disabled={locked.has('subtitle_languages')} className="w-4 h-4 accent-blue-600 disabled:opacity-50" />
{label}
</label>
))}
</fieldset>
<Button type="submit" disabled={locked.has('subtitle_languages')}>Save</Button>
</form>
</SectionCard>
{/* Danger zone */}
<div className="border border-red-400 rounded-lg p-4 mb-4">
<div className="font-semibold text-sm text-red-700 mb-1">Danger Zone</div>
<p className="text-gray-500 text-sm mb-3">These actions are irreversible. Scan data can be regenerated by running a new scan.</p>
<div className="flex items-center gap-4">
<Button variant="danger" onClick={clearScan}>Clear all scan data</Button>
<span className="text-gray-400 text-sm">Removes all scanned items, review plans, and jobs.</span>
</div>
{clearStatus && <p className="text-green-700 text-sm mt-2">{clearStatus}</p>}
</div>
</div>
);
}
import type React from 'react';

View File

@@ -0,0 +1,342 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from '@tanstack/react-router';
import { api } from '~/shared/lib/api';
import { Badge } from '~/shared/components/ui/badge';
import { Button } from '~/shared/components/ui/button';
import { Alert } from '~/shared/components/ui/alert';
import { Select } from '~/shared/components/ui/select';
import { langName, LANG_NAMES } from '~/shared/lib/lang';
import type { MediaItem, MediaStream, SubtitleFile, ReviewPlan, StreamDecision } from '~/shared/lib/types';
// ─── Types ────────────────────────────────────────────────────────────────────
interface DetailData {
item: MediaItem;
subtitleStreams: MediaStream[];
files: SubtitleFile[];
plan: ReviewPlan | null;
decisions: StreamDecision[];
subs_extracted: number;
extractCommand: string | null;
dockerCommand: string | null;
dockerMountDir: string | null;
}
// ─── Utilities ────────────────────────────────────────────────────────────────
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`;
}
function fileName(filePath: string): string {
return filePath.split('/').pop() ?? filePath;
}
function effectiveTitle(s: MediaStream, dec: StreamDecision | undefined): string {
if (dec?.custom_title) return dec.custom_title;
if (!s.language) return '';
const base = langName(s.language);
if (s.is_forced) return `${base} (Forced)`;
if (s.is_hearing_impaired) return `${base} (CC)`;
return base;
}
// ─── Inline edit input ───────────────────────────────────────────────────────
function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
const [localVal, setLocalVal] = useState(value);
useEffect(() => { setLocalVal(value); }, [value]);
return (
<input
type="text"
value={localVal}
onChange={(e) => setLocalVal(e.target.value)}
onBlur={(e) => { if (e.target.value !== value) onCommit(e.target.value); }}
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
placeholder="—"
className="border border-transparent bg-transparent w-full text-[inherit] font-[inherit] text-inherit px-[0.2em] py-[0.05em] rounded outline-none hover:border-gray-300 hover:bg-gray-50 focus:border-blue-300 focus:bg-white min-w-16"
/>
);
}
// ─── Container streams table ─────────────────────────────────────────────────
interface StreamTableProps {
streams: MediaStream[];
decisions: StreamDecision[];
editable: boolean;
onLanguageChange: (streamId: number, lang: string) => void;
onTitleChange: (streamId: number, title: string) => void;
}
function StreamTable({ streams, decisions, editable, onLanguageChange, onTitleChange }: StreamTableProps) {
if (streams.length === 0) return <p className="text-gray-500 text-sm">No subtitle streams in container.</p>;
return (
<table className="w-full border-collapse text-[0.79rem] mt-1">
<thead>
<tr>
{['#', 'Codec', 'Language', 'Title / Info', 'Flags', 'Action'].map((h) => (
<th key={h} className="text-left text-[0.67rem] uppercase tracking-[0.05em] text-gray-500 py-1 px-2 border-b border-gray-200">{h}</th>
))}
</tr>
</thead>
<tbody>
{streams.map((s) => {
const dec = decisions.find((d) => d.stream_id === s.id);
const title = effectiveTitle(s, dec);
const origTitle = s.title;
return (
<tr key={s.id} className="bg-sky-50">
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.stream_index}</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.codec ?? '—'}</td>
<td className="py-1.5 px-2 border-b border-gray-100">
{editable ? (
<Select
value={s.language ?? ''}
onChange={(e) => onLanguageChange(s.id, e.target.value)}
className="text-[0.79rem] py-0.5 px-1.5 w-auto"
>
<option value=""> Unknown </option>
{Object.entries(LANG_NAMES).map(([code, name]) => (
<option key={code} value={code}>{name} ({code})</option>
))}
</Select>
) : (
<>
{langName(s.language)} {s.language ? <span className="text-gray-400 font-mono text-xs">({s.language})</span> : null}
</>
)}
</td>
<td className="py-1.5 px-2 border-b border-gray-100">
{editable ? (
<TitleInput
value={title}
onCommit={(v) => onTitleChange(s.id, v)}
/>
) : (
<span>{title || '—'}</span>
)}
{editable && origTitle && origTitle !== title && (
<div className="text-gray-400 text-[0.7rem] mt-0.5">orig: {origTitle}</div>
)}
</td>
<td className="py-1.5 px-2 border-b border-gray-100">
<span className="inline-flex gap-1">
{s.is_default ? <Badge>default</Badge> : null}
{s.is_forced ? <Badge variant="manual">forced</Badge> : null}
{s.is_hearing_impaired ? <Badge>CC</Badge> : null}
</span>
</td>
<td className="py-1.5 px-2 border-b border-gray-100">
<span className="inline-block border-0 rounded px-2 py-0.5 text-[0.72rem] font-semibold bg-sky-600 text-white min-w-[4.5rem]">
Extract
</span>
</td>
</tr>
);
})}
</tbody>
</table>
);
}
// ─── Extracted files table ────────────────────────────────────────────────────
function ExtractedFilesTable({ files, onDelete }: { files: SubtitleFile[]; onDelete: (fileId: number) => void }) {
if (files.length === 0) return <p className="text-gray-500 text-sm">No extracted files yet.</p>;
return (
<table className="w-full border-collapse text-[0.82rem]">
<thead>
<tr>
{['File', 'Language', 'Codec', 'Flags', 'Size', ''].map((h) => (
<th key={h} className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{files.map((f) => (
<tr key={f.id} className="hover:bg-gray-50">
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs max-w-[360px] truncate" title={f.file_path}>
{fileName(f.file_path)}
</td>
<td className="py-1.5 px-2 border-b border-gray-100">
{f.language ? langName(f.language) : '—'} {f.language ? <span className="text-gray-400 font-mono text-xs">({f.language})</span> : null}
</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{f.codec ?? '—'}</td>
<td className="py-1.5 px-2 border-b border-gray-100">
<span className="inline-flex gap-1">
{f.is_forced ? <Badge variant="manual">forced</Badge> : null}
{f.is_hearing_impaired ? <Badge>CC</Badge> : null}
</span>
</td>
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">
{f.file_size ? formatBytes(f.file_size) : '—'}
</td>
<td className="py-1.5 px-2 border-b border-gray-100 text-right">
<Button variant="danger" size="xs" onClick={() => onDelete(f.id)}>Delete</Button>
</td>
</tr>
))}
</tbody>
</table>
);
}
// ─── Detail page ──────────────────────────────────────────────────────────────
export function SubtitleDetailPage() {
const { id } = useParams({ from: '/review/subtitles/$id' });
const [data, setData] = useState<DetailData | null>(null);
const [loading, setLoading] = useState(true);
const [extracting, setExtracting] = useState(false);
const [rescanning, setRescanning] = useState(false);
const load = () => api.get<DetailData>(`/api/subtitles/${id}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
useEffect(() => { load(); }, [id]);
const changeLanguage = async (streamId: number, lang: string) => {
const d = await api.patch<DetailData>(`/api/subtitles/${id}/stream/${streamId}/language`, { language: lang || null });
setData(d);
};
const changeTitle = async (streamId: number, title: string) => {
const d = await api.patch<DetailData>(`/api/subtitles/${id}/stream/${streamId}/title`, { title });
setData(d);
};
const extract = async () => {
setExtracting(true);
try {
await api.post(`/api/subtitles/${id}/extract`);
load();
} finally { setExtracting(false); }
};
const deleteFile = async (fileId: number) => {
const resp = await api.delete<{ ok: boolean; files: SubtitleFile[] }>(`/api/subtitles/${id}/files/${fileId}`);
if (data) setData({ ...data, files: resp.files });
};
const rescan = async () => {
setRescanning(true);
try { const d = await api.post<DetailData>(`/api/subtitles/${id}/rescan`); setData(d); }
finally { setRescanning(false); }
};
if (loading) return <div className="text-gray-400 py-8 text-center">Loading</div>;
if (!data) return <Alert variant="error">Item not found.</Alert>;
const { item, subtitleStreams, files, decisions, subs_extracted, extractCommand, dockerCommand, dockerMountDir } = data;
const hasContainerSubs = subtitleStreams.length > 0;
const editable = !subs_extracted && hasContainerSubs;
return (
<div>
<div className="flex items-center gap-2 mb-4">
<h1 className="text-xl font-bold m-0">
<Link to="/review/subtitles" className="font-normal mr-2 no-underline text-gray-500 hover:text-gray-700"> Subtitles</Link>
{item.name}
</h1>
</div>
<div className="border border-gray-200 rounded-lg p-4 mb-3">
{/* Meta */}
<dl className="flex flex-wrap gap-5 mb-3 text-[0.82rem]">
{[
{ label: 'Type', value: item.type },
...(item.series_name ? [{ label: 'Series', value: item.series_name }] : []),
...(item.year ? [{ label: 'Year', value: String(item.year) }] : []),
{ label: 'Container', value: item.container ?? '—' },
{ label: 'File size', value: item.file_size ? formatBytes(item.file_size) : '—' },
{ label: 'Status', value: <Badge variant={subs_extracted ? 'done' : 'pending'}>{subs_extracted ? 'extracted' : 'pending'}</Badge> },
].map((entry, i) => (
<div key={i}>
<dt className="text-gray-500 text-[0.68rem] uppercase tracking-[0.05em] mb-0.5">{entry.label}</dt>
<dd className="m-0 font-medium">{entry.value}</dd>
</div>
))}
</dl>
<div className="font-mono text-gray-400 text-[0.78rem] mb-4 break-all">{item.file_path}</div>
{/* Stream table */}
{hasContainerSubs ? (
<StreamTable
streams={subtitleStreams}
decisions={decisions}
editable={editable}
onLanguageChange={changeLanguage}
onTitleChange={changeTitle}
/>
) : (
<Alert variant="warning" className="mb-4">No subtitle streams found in this container.</Alert>
)}
{/* Extracted files */}
{files.length > 0 && (
<div className="mt-4">
<h2 className="text-sm font-semibold mb-2">Extracted Sidecar Files</h2>
<ExtractedFilesTable files={files} onDelete={deleteFile} />
</div>
)}
{/* FFmpeg commands */}
{extractCommand && (
<div className="mt-6">
<div className="text-gray-400 text-[0.75rem] uppercase tracking-[0.05em] mb-1">Extraction command</div>
<textarea
readOnly
rows={3}
value={extractCommand}
className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#9cdcfe] p-3 rounded w-full resize-y border-0 min-h-10"
/>
</div>
)}
{dockerCommand && (
<div className="mt-3">
<div className="flex items-baseline gap-2 mb-1 flex-wrap">
<span className="text-gray-400 text-[0.75rem] uppercase tracking-[0.05em]">Docker (fallback)</span>
<span className="text-gray-400 text-[0.7rem]"> mount: <code className="font-mono">{dockerMountDir}:/work</code></span>
</div>
<textarea
readOnly
rows={3}
value={dockerCommand}
className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#9cdcfe] p-3 rounded w-full resize-y border-0 min-h-10"
/>
</div>
)}
{/* Actions */}
{hasContainerSubs && !subs_extracted && (
<div className="flex gap-2 mt-6">
<Button onClick={extract} disabled={extracting}>
{extracting ? 'Queuing…' : '✓ Extract All'}
</Button>
</div>
)}
{subs_extracted ? (
<Alert variant="success" className="mt-4">Subtitles have been extracted to sidecar files.</Alert>
) : null}
{/* Refresh */}
<div className="flex items-center gap-3 mt-6 pt-3 border-t border-gray-200">
<Button variant="secondary" size="sm" onClick={rescan} disabled={rescanning}>
{rescanning ? '↻ Refreshing…' : '↻ Refresh from Jellyfin'}
</Button>
<span className="text-gray-400 text-[0.75rem]">
{rescanning ? 'Triggering Jellyfin metadata probe and waiting for completion…' : 'Triggers a metadata re-probe in Jellyfin, then re-fetches stream data'}
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
import { useState, useEffect } from 'react';
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
import { api } from '~/shared/lib/api';
import { Badge } from '~/shared/components/ui/badge';
import { langName } from '~/shared/lib/lang';
import type { MediaItem } from '~/shared/lib/types';
// ─── Types ────────────────────────────────────────────────────────────────────
interface SubtitleListItem extends Pick<MediaItem, 'id' | 'jellyfin_id' | 'type' | 'name' | 'series_name' | 'season_number' | 'episode_number' | 'year' | 'original_language' | 'file_path'> {
subs_extracted: number | null;
sub_count: number;
file_count: number;
}
interface SubtitleListData {
items: SubtitleListItem[];
filter: string;
totalCounts: Record<string, number>;
}
const FILTER_TABS = [
{ key: 'all', label: 'All' },
{ key: 'not_extracted', label: 'Not Extracted' },
{ key: 'extracted', label: 'Extracted' },
{ key: 'no_subs', label: 'No Subtitles' },
];
// ─── Main page ────────────────────────────────────────────────────────────────
export function SubtitleListPage() {
const { filter } = useSearch({ from: '/review/subtitles/' });
const navigate = useNavigate();
const [data, setData] = useState<SubtitleListData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get<SubtitleListData>(`/api/subtitles?filter=${filter}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
}, [filter]);
if (loading) return <div className="text-gray-400 py-8 text-center">Loading</div>;
if (!data) return <div className="text-red-600">Failed to load.</div>;
const { items, totalCounts } = data;
return (
<div>
<h1 className="text-xl font-bold m-0 mb-4">Subtitle Manager</h1>
{/* Filter tabs */}
<div className="flex gap-1 flex-wrap mb-3 items-center">
{FILTER_TABS.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => navigate({ to: '/review/subtitles', search: { filter: tab.key } as never })}
className={`px-2.5 py-0.5 rounded text-[0.8rem] border cursor-pointer transition-colors leading-[1.4] ${filter === tab.key ? 'bg-blue-600 border-blue-600 text-white' : 'border-gray-200 bg-transparent text-gray-500 hover:bg-gray-50'}`}
>
{tab.label}
{totalCounts[tab.key] != null && <> <span className="text-[0.72rem] font-bold">{totalCounts[tab.key]}</span></>}
</button>
))}
</div>
{items.length === 0 && (
<p className="text-gray-500">No items match this filter.</p>
)}
{items.length > 0 && (
<table className="w-full border-collapse text-[0.82rem]">
<thead>
<tr>
{['Name', 'Lang', 'Container Subs', 'Extracted Files', 'Status'].map((h) => (
<th key={h} className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{items.map((item) => {
const status = item.sub_count === 0 ? 'no_subs' : item.subs_extracted ? 'extracted' : 'not_extracted';
return (
<tr key={item.id} className="hover:bg-gray-50">
<td className="py-1.5 px-2 border-b border-gray-100 align-middle">
<Link to="/review/subtitles/$id" params={{ id: String(item.id) }} className="no-underline text-blue-600 hover:text-blue-800">
<span className="truncate inline-block max-w-[360px]" title={item.name}>{item.name}</span>
</Link>
{item.series_name && <span className="text-gray-400 text-[0.72rem]"> {item.series_name}</span>}
{item.year && <span className="text-gray-400 text-[0.72rem]"> ({item.year})</span>}
</td>
<td className="py-1.5 px-2 border-b border-gray-100 align-middle">{langName(item.original_language)}</td>
<td className="py-1.5 px-2 border-b border-gray-100 align-middle font-mono text-xs">{item.sub_count}</td>
<td className="py-1.5 px-2 border-b border-gray-100 align-middle font-mono text-xs">{item.file_count}</td>
<td className="py-1.5 px-2 border-b border-gray-100 align-middle">
{status === 'extracted' && <Badge variant="keep">extracted</Badge>}
{status === 'not_extracted' && <Badge variant="pending">pending</Badge>}
{status === 'no_subs' && <Badge variant="noop">no subs</Badge>}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
);
}

6
src/index.css Normal file
View File

@@ -0,0 +1,6 @@
@import "tailwindcss";
@layer base {
* { box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; }
}

20
src/main.tsx Normal file
View File

@@ -0,0 +1,20 @@
import './index.css';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider, createRouter } from '@tanstack/react-router';
import { routeTree } from './routeTree.gen';
const router = createRouter({ routeTree, defaultPreload: 'intent' });
declare module '@tanstack/react-router' {
interface Register { router: typeof router; }
}
const root = document.getElementById('root');
if (!root) throw new Error('No #root element found');
createRoot(root).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);

48
src/routes/__root.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { createRootRoute, Link, Outlet } from '@tanstack/react-router';
import { cn } from '~/shared/lib/utils';
export const Route = createRootRoute({
component: RootLayout,
});
function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
return (
<Link
to={to}
className={cn('px-2.5 py-1 rounded text-[0.85rem] no-underline transition-colors text-gray-500 hover:bg-gray-100 hover:text-gray-900')}
activeProps={{ className: 'bg-gray-100 text-gray-900 font-medium' }}
>
{children}
</Link>
);
}
function RootLayout() {
const isDev = import.meta.env.DEV;
return (
<div className="min-h-screen bg-white text-gray-900 text-sm">
{isDev && (
<div className="bg-amber-100 border-b border-amber-300 text-amber-800 text-xs font-semibold text-center py-1 px-4">
DEV MODE fresh test database, 50 movies + 10 series per scan
</div>
)}
<nav className="flex items-center gap-0.5 px-5 h-12 bg-white border-b border-gray-200 sticky top-0 z-50">
<Link to="/" className="font-bold text-[0.95rem] mr-5 no-underline text-gray-900">
🎬 netfelix-audio-fix
</Link>
<NavLink to="/scan">Scan</NavLink>
<NavLink to="/review/audio">Audio</NavLink>
<NavLink to="/review/subtitles">Subtitles</NavLink>
<NavLink to="/execute">Execute</NavLink>
<div className="flex-1" />
<NavLink to="/nodes">Nodes</NavLink>
<NavLink to="/setup">Settings</NavLink>
</nav>
<main className="max-w-[1600px] mx-auto px-5 pt-4 pb-12">
<Outlet />
</main>
</div>
);
}
import type React from 'react';

6
src/routes/execute.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { ExecutePage } from '~/features/execute/ExecutePage';
export const Route = createFileRoute('/execute')({
component: ExecutePage,
});

6
src/routes/index.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { DashboardPage } from '~/features/dashboard/DashboardPage';
export const Route = createFileRoute('/')({
component: DashboardPage,
});

6
src/routes/nodes.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { NodesPage } from '~/features/nodes/NodesPage';
export const Route = createFileRoute('/nodes')({
component: NodesPage,
});

5
src/routes/review.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { createFileRoute, Outlet } from '@tanstack/react-router';
export const Route = createFileRoute('/review')({
component: () => <Outlet />,
});

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { AudioDetailPage } from '~/features/review/AudioDetailPage';
export const Route = createFileRoute('/review/audio/$id')({
component: AudioDetailPage,
});

View File

@@ -0,0 +1,10 @@
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
import { AudioListPage } from '~/features/review/AudioListPage';
export const Route = createFileRoute('/review/audio/')({
validateSearch: z.object({
filter: z.enum(['all', 'needs_action', 'noop', 'manual', 'approved', 'skipped', 'done', 'error']).default('all'),
}),
component: AudioListPage,
});

View File

@@ -0,0 +1,5 @@
import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/review/')({
beforeLoad: () => { throw redirect({ to: '/review/audio' }); },
});

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { SubtitleDetailPage } from '~/features/subtitles/SubtitleDetailPage';
export const Route = createFileRoute('/review/subtitles/$id')({
component: SubtitleDetailPage,
});

View File

@@ -0,0 +1,10 @@
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
import { SubtitleListPage } from '~/features/subtitles/SubtitleListPage';
export const Route = createFileRoute('/review/subtitles/')({
validateSearch: z.object({
filter: z.enum(['all', 'not_extracted', 'extracted', 'no_subs']).default('all'),
}),
component: SubtitleListPage,
});

6
src/routes/scan.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { ScanPage } from '~/features/scan/ScanPage';
export const Route = createFileRoute('/scan')({
component: ScanPage,
});

6
src/routes/setup.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { SetupPage } from '~/features/setup/SetupPage';
export const Route = createFileRoute('/setup')({
component: SetupPage,
});

View File

@@ -1,83 +0,0 @@
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,
};

View File

@@ -1,186 +0,0 @@
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`;
}

View File

@@ -1,124 +0,0 @@
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(' · ');
}

View File

@@ -1,151 +0,0 @@
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;
}

View File

@@ -0,0 +1,21 @@
import type React from 'react';
import { cn } from '~/shared/lib/utils';
const variants = {
info: 'bg-cyan-50 text-cyan-800 border border-cyan-200',
warning: 'bg-amber-50 text-amber-800 border border-amber-200',
error: 'bg-red-50 text-red-800 border border-red-200',
success: 'bg-green-50 text-green-800 border border-green-200',
} as const;
interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: keyof typeof variants;
}
export function Alert({ variant = 'info', className, children, ...props }: AlertProps) {
return (
<div className={cn('p-3 rounded text-sm', variants[variant], className)} {...props}>
{children}
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { cn } from '~/shared/lib/utils';
const variants = {
default: 'bg-gray-100 text-gray-600',
keep: 'bg-green-100 text-green-800',
remove: 'bg-red-100 text-red-800',
pending: 'bg-gray-200 text-gray-600',
approved: 'bg-green-100 text-green-800',
skipped: 'bg-gray-200 text-gray-600',
done: 'bg-cyan-100 text-cyan-800',
error: 'bg-red-100 text-red-800',
noop: 'bg-gray-200 text-gray-600',
running: 'bg-amber-100 text-amber-800',
manual: 'bg-orange-100 text-orange-800',
} as const;
interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
variant?: keyof typeof variants;
}
export function Badge({ variant = 'default', className, children, ...props }: BadgeProps) {
return (
<span
className={cn(
'inline-block text-[0.67rem] font-semibold px-[0.45em] py-[0.1em] rounded-full uppercase tracking-[0.03em] whitespace-nowrap',
variants[variant],
className,
)}
{...props}
>
{children}
</span>
);
}
import type React from 'react';

View File

@@ -0,0 +1,26 @@
import type React from 'react';
import { cn } from '~/shared/lib/utils';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'default' | 'sm' | 'xs';
}
export function Button({ variant = 'primary', size = 'default', className, ...props }: ButtonProps) {
return (
<button
className={cn(
'inline-flex items-center justify-center gap-1 rounded font-medium cursor-pointer transition-colors border-0',
variant === 'primary' && 'bg-blue-600 text-white hover:bg-blue-700',
variant === 'secondary' && 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50',
variant === 'danger' && 'bg-white text-red-600 border border-red-400 hover:bg-red-50',
size === 'default' && 'px-3 py-1.5 text-sm',
size === 'sm' && 'px-2.5 py-1 text-xs',
size === 'xs' && 'px-2 py-0.5 text-xs',
props.disabled && 'opacity-50 cursor-not-allowed',
className,
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,16 @@
import type React from 'react';
import { cn } from '~/shared/lib/utils';
export function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
className={cn(
'border border-gray-300 rounded px-2.5 py-1.5 text-sm bg-white w-full',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
'disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed',
className,
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,16 @@
import type React from 'react';
import { cn } from '~/shared/lib/utils';
export function Select({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select
className={cn(
'border border-gray-300 rounded px-2 py-1.5 text-sm bg-white',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
'disabled:bg-gray-100 disabled:cursor-not-allowed',
className,
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,15 @@
import type React from 'react';
import { cn } from '~/shared/lib/utils';
export function Textarea({ className, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
return (
<textarea
className={cn(
'border border-gray-300 rounded px-2.5 py-1.5 text-sm bg-white w-full resize-vertical',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
className,
)}
{...props}
/>
);
}

26
src/shared/lib/api.ts Normal file
View File

@@ -0,0 +1,26 @@
/** Base URL for API calls. In dev Vite proxies /api → :3000. */
const BASE = '';
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(BASE + path, {
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
...init,
});
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(text || `HTTP ${res.status}`);
}
return res.json() as Promise<T>;
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined }),
patch: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PATCH', body: body !== undefined ? JSON.stringify(body) : undefined }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
/** POST multipart/form-data (file upload). Omit Content-Type so browser sets boundary. */
postForm: <T>(path: string, body: FormData) =>
request<T>(path, { method: 'POST', body, headers: {} }),
};

18
src/shared/lib/lang.ts Normal file
View File

@@ -0,0 +1,18 @@
export 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',
};
export const KNOWN_LANG_NAMES = new Set(Object.values(LANG_NAMES).map((n) => n.toLowerCase()));
export function langName(code: string | null | undefined): string {
if (!code) return '—';
return LANG_NAMES[code.toLowerCase()] ?? code.toUpperCase();
}

100
src/shared/lib/types.ts Normal file
View File

@@ -0,0 +1,100 @@
// Client-side type definitions mirroring server/types.ts
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: string | null;
needs_review: number;
imdb_id: string | null;
tmdb_id: string | null;
tvdb_id: string | null;
scan_status: string;
last_scanned_at: string | null;
}
export interface MediaStream {
id: number;
item_id: number;
stream_index: number;
type: string;
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: string;
is_noop: number;
subs_extracted: number;
notes: string | null;
reviewed_at: string | null;
created_at: string;
}
export interface SubtitleFile {
id: number;
item_id: number;
file_path: string;
language: string | null;
codec: string | null;
is_forced: number;
is_hearing_impaired: number;
file_size: number | null;
created_at: string;
}
export interface StreamDecision {
id: number;
plan_id: number;
stream_id: number;
action: 'keep' | 'remove';
target_index: number | null;
custom_title: string | null;
}
export interface Job {
id: number;
item_id: number;
node_id: number | null;
command: string;
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: string;
last_checked_at: string | null;
}

6
src/shared/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,74 +0,0 @@
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>
);

View File

@@ -1,182 +0,0 @@
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>
)}
</>
);
};

View File

@@ -1,38 +0,0 @@
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}</>;

View File

@@ -1,130 +0,0 @@
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>
);

View File

@@ -1,371 +0,0 @@
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`;
}

View File

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

View File

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

View File

@@ -4,11 +4,13 @@
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"strict": true,
"skipLibCheck": true,
"types": ["bun-types"],
"lib": ["ESNext"]
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
}
},
"include": ["src/**/*"]
"include": ["src/**/*", "vite.config.ts"]
}

14
tsconfig.server.json Normal file
View File

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

28
vite.config.ts Normal file
View File

@@ -0,0 +1,28 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import tailwindcss from '@tailwindcss/vite';
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
import { resolve } from 'node:path';
export default defineConfig({
plugins: [
TanStackRouterVite({ target: 'react', autoCodeSplitting: true }),
react(),
tailwindcss(),
],
resolve: {
alias: {
'~': resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
proxy: {
'/api': { target: 'http://localhost:3000', changeOrigin: true },
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
});