diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fae1519 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 72ed8fd..68ecc02 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bbe86bc --- /dev/null +++ b/AGENTS.md @@ -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 `` 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 { + 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((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 `` for internal links — use TanStack Router's `` +- ❌ 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 diff --git a/Dockerfile b/Dockerfile index 296ac2a..bd003fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..1c40799 --- /dev/null +++ b/biome.json @@ -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"] + } +} diff --git a/bun.lock b/bun.lock index 758c010..ac7822b 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], } } diff --git a/index.html b/index.html new file mode 100644 index 0000000..e806e0c --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + netfelix-audio-fix + + +
+ + + diff --git a/package.json b/package.json index ba91f25..78c8542 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/public/app.css b/public/app.css deleted file mode 100644 index 0115bb8..0000000 --- a/public/app.css +++ /dev/null @@ -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; -} diff --git a/server/api/dashboard.ts b/server/api/dashboard.ts new file mode 100644 index 0000000..a408bfd --- /dev/null +++ b/server/api/dashboard.ts @@ -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; diff --git a/server/api/execute.ts b/server/api/execute.ts new file mode 100644 index 0000000..db2f4a6 --- /dev/null +++ b/server/api/execute.ts @@ -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((res) => { resolve = res; setTimeout(res, 15_000); }); + resolve = null; + if (queue.length === 0) await s.write(': keepalive\n\n'); + } + } + } finally { + jobListeners.delete(listener); + } + }); +}); + +// ─── Job execution ──────────────────────────────────────────────────────────── + +async function runJob(job: Job): Promise { + const db = getDb(); + + 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, 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; diff --git a/server/api/nodes.ts b/server/api/nodes.ts new file mode 100644 index 0000000..442a92b --- /dev/null +++ b/server/api/nodes.ts @@ -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; diff --git a/server/api/review.ts b/server/api/review.ts new file mode 100644 index 0000000..b8039dc --- /dev/null +++ b/server/api/review.ts @@ -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): Record { + const total = (db.prepare('SELECT COUNT(*) as n FROM review_plans').get() as { n: number }).n; + const noops = (db.prepare('SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1').get() as { n: number }).n; + const pending = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }).n; + const approved = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").get() as { n: number }).n; + const skipped = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'skipped'").get() as { n: number }).n; + const done = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'done'").get() as { n: number }).n; + const error = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'error'").get() as { n: number }).n; + const manual = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE needs_review = 1 AND original_language IS NULL").get() as { n: number }).n; + return { all: total, needs_action: pending, noop: noops, approved, skipped, done, error, manual }; +} + +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, 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, 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( + (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(); + 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; diff --git a/src/api/scan.tsx b/server/api/scan.ts similarity index 51% rename from src/api/scan.tsx rename to server/api/scan.ts index b190a64..500a75c 100644 --- a/src/api/scan.tsx +++ b/server/api/scan.ts @@ -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( - - ); + 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((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 { +async function runScan(limit: number | null = null): Promise { 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 { 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 { 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 { 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; diff --git a/server/api/setup.ts b/server/api/setup.ts new file mode 100644 index 0000000..dc7e93f --- /dev/null +++ b/server/api/setup.ts @@ -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; diff --git a/server/api/subtitles.ts b/server/api/subtitles.ts new file mode 100644 index 0000000..3bf3fa0 --- /dev/null +++ b/server/api/subtitles.ts @@ -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, 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 & { + 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; diff --git a/server/db/index.ts b/server/db/index.ts new file mode 100644 index 0000000..2fb1b08 --- /dev/null +++ b/server/db/index.ts @@ -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 = { + 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 { + const locked = new Set(); + for (const key of Object.keys(ENV_MAP)) { + if (envValue(key) !== null) locked.add(key); + } + return locked; +} + +export function getAllConfig(): Record { + 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; +} diff --git a/src/db/schema.ts b/server/db/schema.ts similarity index 87% rename from src/db/schema.ts rename to server/db/schema.ts index ea969a8..8cdb0e5 100644 --- a/src/db/schema.ts +++ b/server/db/schema.ts @@ -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 = { sonarr_enabled: '0', subtitle_languages: JSON.stringify(['eng', 'deu', 'spa']), scan_running: '0', + movies_path: '', + series_path: '', }; diff --git a/server/index.tsx b/server/index.tsx new file mode 100644 index 0000000..633fd66 --- /dev/null +++ b/server/index.tsx @@ -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, +}; diff --git a/server/services/analyzer.ts b/server/services/analyzer.ts new file mode 100644 index 0000000..a25d98f --- /dev/null +++ b/server/services/analyzer.ts @@ -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, + 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 = {}; + 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; +} diff --git a/server/services/ffmpeg.ts b/server/services/ffmpeg.ts new file mode 100644 index 0000000..92ceea2 --- /dev/null +++ b/server/services/ffmpeg.ts @@ -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 = { + 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 = { + 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(); + 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(); + 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 = { + 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 = { 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(); + const counts: Record = {}; + 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 = { 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 = { 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 = { 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(' · '); +} diff --git a/server/services/jellyfin.ts b/server/services/jellyfin.ts new file mode 100644 index 0000000..1656ab2 --- /dev/null +++ b/server/services/jellyfin.ts @@ -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 { + return { + 'X-Emby-Token': apiKey, + 'Content-Type': 'application/json', + }; +} + +export async function testConnection(cfg: JellyfinConfig): Promise<{ ok: boolean; error?: string }> { + try { + const res = await fetch(`${cfg.url}/Users`, { + headers: headers(cfg.apiKey), + }); + if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }; + return { ok: true }; + } catch (e) { + return { ok: false, error: String(e) }; + } +} + +export async function getUsers(cfg: Pick): Promise { + const res = await fetch(`${cfg.url}/Users`, { headers: headers(cfg.apiKey) }); + if (!res.ok) throw new Error(`Jellyfin /Users failed: ${res.status}`); + return res.json() as Promise; +} + +const ITEM_FIELDS = [ + 'MediaStreams', + 'Path', + 'ProviderIds', + 'OriginalTitle', + 'ProductionYear', + 'Size', + 'Container', +].join(','); + +export async function* getAllItems( + cfg: JellyfinConfig, + onProgress?: (count: number, total: number) => void +): AsyncGenerator { + 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 { + // 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 { + 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; +} + +/** + * 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 { + 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 { + return { + stream_index: s.Index, + type: s.Type as MediaStream['type'], + codec: s.Codec ?? null, + language: s.Language ? normalizeLanguage(s.Language) : null, + language_display: s.DisplayLanguage ?? null, + title: s.Title ?? null, + is_default: s.IsDefault ? 1 : 0, + is_forced: s.IsForced ? 1 : 0, + is_hearing_impaired: s.IsHearingImpaired ? 1 : 0, + channels: s.Channels ?? null, + channel_layout: s.ChannelLayout ?? null, + bit_rate: s.BitRate ?? null, + sample_rate: s.SampleRate ?? null, + }; +} + +// ISO 639-2/T → ISO 639-2/B normalization + common aliases +const LANG_ALIASES: Record = { + // German: both /T (deu) and /B (ger) → deu + ger: 'deu', + // Chinese + chi: 'zho', + // French + fre: 'fra', + // Dutch + dut: 'nld', + // Modern Greek + gre: 'ell', + // Hebrew + heb: 'heb', + // Farsi + per: 'fas', + // Romanian + rum: 'ron', + // Malay + may: 'msa', + // Tibetan + tib: 'bod', + // Burmese + bur: 'mya', + // Czech + cze: 'ces', + // Slovak + slo: 'slk', + // Georgian + geo: 'kat', + // Icelandic + ice: 'isl', + // Armenian + arm: 'hye', + // Basque + baq: 'eus', + // Albanian + alb: 'sqi', + // Macedonian + mac: 'mkd', + // Welsh + wel: 'cym', +}; + +export function normalizeLanguage(lang: string): string { + const lower = lang.toLowerCase().trim(); + return LANG_ALIASES[lower] ?? lower; +} diff --git a/src/services/radarr.ts b/server/services/radarr.ts similarity index 100% rename from src/services/radarr.ts rename to server/services/radarr.ts diff --git a/src/services/sonarr.ts b/server/services/sonarr.ts similarity index 100% rename from src/services/sonarr.ts rename to server/services/sonarr.ts diff --git a/src/services/ssh.ts b/server/services/ssh.ts similarity index 100% rename from src/services/ssh.ts rename to server/services/ssh.ts diff --git a/src/types.ts b/server/types.ts similarity index 92% rename from src/types.ts rename to server/types.ts index 7cf2d8e..e04d386 100644 --- a/src/types.ts +++ b/server/types.ts @@ -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; diff --git a/src/api/execute.tsx b/src/api/execute.tsx deleted file mode 100644 index 80691bb..0000000 --- a/src/api/execute.tsx +++ /dev/null @@ -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(); -}); - -// ─── Start all pending ──────────────────────────────────────────────────────── - -app.post('/start', (c) => { - const db = getDb(); - const pending = db.prepare( - "SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at" - ).all() as Job[]; - - for (const job of pending) { - runJob(job).catch((err) => console.error(`Job ${job.id} failed:`, err)); - } - - return c.redirect('/execute'); -}); - -// ─── Assign node ────────────────────────────────────────────────────────────── - -app.post('/job/:id/assign', async (c) => { - const db = getDb(); - const jobId = Number(c.req.param('id')); - const body = await c.req.formData(); - const nodeId = body.get('node_id') ? Number(body.get('node_id')) : null; - - db.prepare('UPDATE jobs SET node_id = ? WHERE id = ?').run(nodeId, jobId); - return c.redirect('/execute'); -}); - -// ─── Run single job ─────────────────────────────────────────────────────────── - -app.post('/job/:id/run', async (c) => { - const db = getDb(); - const jobId = Number(c.req.param('id')); - const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(jobId) as Job | undefined; - - if (!job || job.status !== 'pending') { - return c.redirect('/execute'); - } - - runJob(job).catch((err) => console.error(`Job ${job.id} failed:`, err)); - return c.redirect('/execute'); -}); - -// ─── Cancel job ─────────────────────────────────────────────────────────────── - -app.post('/job/:id/cancel', (c) => { - const db = getDb(); - const jobId = Number(c.req.param('id')); - db.prepare("DELETE FROM jobs WHERE id = ? AND status = 'pending'").run(jobId); - return c.redirect('/execute'); -}); - -// ─── SSE ────────────────────────────────────────────────────────────────────── - -app.get('/events', (c) => { - return stream(c, async (s) => { - c.header('Content-Type', 'text/event-stream'); - c.header('Cache-Control', 'no-cache'); - - const queue: string[] = []; - let resolve: (() => void) | null = null; - - const listener = (data: string) => { - queue.push(data); - resolve?.(); - }; - - jobListeners.add(listener); - s.onAbort(() => { jobListeners.delete(listener); }); - - try { - while (!s.closed) { - if (queue.length > 0) { - await s.write(queue.shift()!); - } else { - await new Promise((res) => { - resolve = res; - setTimeout(res, 15_000); - }); - resolve = null; - if (queue.length === 0) await s.write(': keepalive\n\n'); - } - } - } finally { - jobListeners.delete(listener); - } - }); -}); - -// ─── Job execution ──────────────────────────────────────────────────────────── - -async function runJob(job: Job): Promise { - const db = getDb(); - - db.prepare( - "UPDATE jobs SET status = 'running', started_at = datetime('now'), output = '' WHERE id = ?" - ).run(job.id); - emitJobUpdate(job.id, 'running'); - - let outputLines: string[] = []; - - try { - if (job.node_id) { - // Remote execution - const node = db.prepare('SELECT * FROM nodes WHERE id = ?').get(job.node_id) as Node | undefined; - if (!node) throw new Error(`Node ${job.node_id} not found`); - - for await (const line of execStream(node, job.command)) { - outputLines.push(line); - // Flush to DB every 20 lines - if (outputLines.length % 20 === 0) { - db.prepare('UPDATE jobs SET output = ? WHERE id = ?').run(outputLines.join('\n'), job.id); - emitJobUpdate(job.id, 'running', outputLines.join('\n')); - } - } - } else { - // Local execution — spawn ffmpeg directly - const proc = Bun.spawn(['sh', '-c', job.command], { - stdout: 'pipe', - stderr: 'pipe', - }); - - const readStream = async (readable: ReadableStream, prefix = '') => { - const reader = readable.getReader(); - const decoder = new TextDecoder(); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - const text = decoder.decode(value); - const lines = text.split('\n').filter((l) => l.trim()); - for (const line of lines) { - outputLines.push(prefix + line); - } - if (outputLines.length % 20 === 0) { - db.prepare('UPDATE jobs SET output = ? WHERE id = ?').run(outputLines.join('\n'), job.id); - emitJobUpdate(job.id, 'running', outputLines.join('\n')); - } - } - } catch { /* ignore */ } - }; - - await Promise.all([ - readStream(proc.stdout), - readStream(proc.stderr, '[stderr] '), - proc.exited, - ]); - - const exitCode = await proc.exited; - if (exitCode !== 0) { - throw new Error(`FFmpeg exited with code ${exitCode}`); - } - } - - const fullOutput = outputLines.join('\n'); - db.prepare( - "UPDATE jobs SET status = 'done', exit_code = 0, output = ?, completed_at = datetime('now') WHERE id = ?" - ).run(fullOutput, job.id); - emitJobUpdate(job.id, 'done', fullOutput); - - // Mark plan as done - db.prepare( - "UPDATE review_plans SET status = 'done' WHERE item_id = ?" - ).run(job.item_id); - } catch (err) { - const fullOutput = outputLines.join('\n') + '\n' + String(err); - db.prepare( - "UPDATE jobs SET status = 'error', exit_code = 1, output = ?, completed_at = datetime('now') WHERE id = ?" - ).run(fullOutput, job.id); - emitJobUpdate(job.id, 'error', fullOutput); - - db.prepare( - "UPDATE review_plans SET status = 'error' WHERE item_id = ?" - ).run(job.item_id); - } -} - -export default app; diff --git a/src/api/nodes.tsx b/src/api/nodes.tsx deleted file mode 100644 index f0fef26..0000000 --- a/src/api/nodes.tsx +++ /dev/null @@ -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(); -}); - -app.post('/', async (c) => { - const db = getDb(); - const body = await c.req.formData(); - - const name = body.get('name') as string; - const host = body.get('host') as string; - const port = Number(body.get('port') ?? '22'); - const username = body.get('username') as string; - const ffmpegPath = (body.get('ffmpeg_path') as string) || 'ffmpeg'; - const workDir = (body.get('work_dir') as string) || '/tmp'; - const keyFile = body.get('private_key') as File | null; - - if (!name || !host || !username || !keyFile) { - return c.html(
All fields are required.
); - } - - const privateKey = await keyFile.text(); - - try { - db.prepare(` - INSERT INTO nodes (name, host, port, username, private_key, ffmpeg_path, work_dir) - VALUES (?, ?, ?, ?, ?, ?, ?) - `).run(name, host, port, username, privateKey, ffmpegPath, workDir); - } catch (e) { - if (String(e).includes('UNIQUE')) { - return c.html(
A node named "{name}" already exists.
); - } - throw e; - } - - const nodes = db.prepare('SELECT * FROM nodes ORDER BY name').all() as Node[]; - return c.html(); -}); - -app.post('/:id/delete', (c) => { - const db = getDb(); - const id = Number(c.req.param('id')); - db.prepare('DELETE FROM nodes WHERE id = ?').run(id); - return c.redirect('/nodes'); -}); - -app.post('/:id/test', async (c) => { - const db = getDb(); - const id = Number(c.req.param('id')); - const node = db.prepare('SELECT * FROM nodes WHERE id = ?').get(id) as Node | undefined; - - if (!node) return c.notFound(); - - const result = await testConnection(node); - const status = result.ok ? 'ok' : 'error'; - - db.prepare( - "UPDATE nodes SET status = ?, last_checked_at = datetime('now') WHERE id = ?" - ).run(status, id); - - return c.html(); -}); - -export default app; diff --git a/src/api/review.tsx b/src/api/review.tsx deleted file mode 100644 index e73244a..0000000 --- a/src/api/review.tsx +++ /dev/null @@ -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): Record { - const total = (db.prepare('SELECT COUNT(*) as n FROM review_plans').get() as { n: number }).n; - const noops = (db.prepare('SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1').get() as { n: number }).n; - const pending = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }).n; - const approved = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").get() as { n: number }).n; - const skipped = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'skipped'").get() as { n: number }).n; - const done = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'done'").get() as { n: number }).n; - const error = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'error'").get() as { n: number }).n; - const manual = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE needs_review = 1 AND original_language IS NULL").get() as { n: number }).n; - - return { all: total, needs_action: pending, noop: noops, approved, skipped, done, error, manual }; -} - -// ─── List view ──────────────────────────────────────────────────────────────── - -app.get('/', (c) => { - const db = getDb(); - const filter = c.req.query('filter') ?? 'all'; - - let whereClause = '1=1'; - switch (filter) { - case 'needs_action': whereClause = "rp.status = 'pending' AND rp.is_noop = 0"; break; - case 'noop': whereClause = 'rp.is_noop = 1'; break; - case 'manual': whereClause = 'mi.needs_review = 1 AND mi.original_language IS NULL'; break; - case 'approved': whereClause = "rp.status = 'approved'"; break; - case 'skipped': whereClause = "rp.status = 'skipped'"; break; - case 'done': whereClause = "rp.status = 'done'"; break; - case 'error': whereClause = "rp.status = 'error'"; break; - } - - const rows = db.prepare(` - SELECT - mi.*, - rp.id as plan_id, - rp.status as plan_status, - rp.is_noop, - rp.notes as plan_notes, - rp.reviewed_at, - rp.created_at as plan_created_at, - COUNT(CASE WHEN sd.action = 'remove' THEN 1 END) as remove_count, - COUNT(CASE WHEN sd.action = 'keep' THEN 1 END) as keep_count - FROM media_items mi - LEFT JOIN review_plans rp ON rp.item_id = mi.id - LEFT JOIN stream_decisions sd ON sd.plan_id = rp.id - WHERE ${whereClause} - GROUP BY mi.id - ORDER BY mi.series_name NULLS LAST, mi.name, mi.season_number, mi.episode_number - LIMIT 500 - `).all() as (MediaItem & { - plan_id: number | null; - plan_status: string | null; - is_noop: number | null; - plan_notes: string | null; - reviewed_at: string | null; - plan_created_at: string | null; - remove_count: number; - keep_count: number; - })[]; - - const items = rows.map((r) => ({ - item: r as unknown as MediaItem, - plan: r.plan_id != null ? { - id: r.plan_id, - item_id: r.id, - status: r.plan_status ?? 'pending', - is_noop: r.is_noop ?? 0, - notes: r.plan_notes, - reviewed_at: r.reviewed_at, - created_at: r.plan_created_at ?? '', - } as ReviewPlan : null, - removeCount: r.remove_count, - keepCount: r.keep_count, - })); - - const totalCounts = countsByFilter(db); - - return c.html(); -}); - -// ─── Detail view ────────────────────────────────────────────────────────────── - -app.get('/:id', (c) => { - const db = getDb(); - const id = Number(c.req.param('id')); - const { item, streams, plan, decisions, command } = loadItemDetail(db, id); - - if (!item) return c.notFound(); - - // Inline HTMX expansion vs full page - const isHtmx = c.req.header('HX-Request') === 'true'; - if (isHtmx) { - return c.html( - - ); - } - - return c.html( - - ); -}); - -// ─── Override original language ─────────────────────────────────────────────── - -app.patch('/:id/language', async (c) => { - const db = getDb(); - const id = Number(c.req.param('id')); - const body = await c.req.formData(); - const lang = (body.get('language') as string) || null; - - db.prepare( - "UPDATE media_items SET original_language = ?, orig_lang_source = 'manual', needs_review = 0 WHERE id = ?" - ).run(lang ? normalizeLanguage(lang) : null, id); - - // Re-analyze with new language - reanalyze(db, id); - - const { item, streams, plan, decisions, command } = loadItemDetail(db, id); - if (!item) return c.notFound(); - - return c.html( - - ); -}); - -// ─── Toggle stream action ───────────────────────────────────────────────────── - -app.patch('/:id/stream/:streamId', async (c) => { - const db = getDb(); - const itemId = Number(c.req.param('id')); - const streamId = Number(c.req.param('streamId')); - const body = await c.req.formData(); - const action = body.get('action') as 'keep' | 'remove'; - - // Get plan - const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number } | undefined; - if (!plan) return c.notFound(); - - db.prepare( - 'UPDATE stream_decisions SET action = ? WHERE plan_id = ? AND stream_id = ?' - ).run(action, plan.id, streamId); - - // Recompute is_noop - const allKeep = (db.prepare( - "SELECT COUNT(*) as n FROM stream_decisions WHERE plan_id = ? AND action = 'remove'" - ).get(plan.id) as { n: number }).n === 0; - db.prepare('UPDATE review_plans SET is_noop = ? WHERE id = ?').run(allKeep ? 1 : 0, plan.id); - - const { item, streams, decisions, command } = loadItemDetail(db, itemId); - if (!item) return c.notFound(); - const planFull = db.prepare('SELECT * FROM review_plans WHERE id = ?').get(plan.id) as ReviewPlan; - - return c.html( - - ); -}); - -// ─── Approve ────────────────────────────────────────────────────────────────── - -app.post('/:id/approve', (c) => { - const db = getDb(); - const id = Number(c.req.param('id')); - - const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(id) as ReviewPlan | undefined; - if (!plan) return c.notFound(); - - db.prepare( - "UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?" - ).run(plan.id); - - // Create job - if (!plan.is_noop) { - const { item, streams, decisions } = loadItemDetail(db, id); - if (item) { - const command = buildCommand(item, streams, decisions); - db.prepare( - "INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')" - ).run(id, command); - } - } - - const isHtmx = c.req.header('HX-Request') === 'true'; - return isHtmx ? c.redirect('/review', 303) : c.redirect('/review'); -}); - -// ─── Skip ───────────────────────────────────────────────────────────────────── - -app.post('/:id/skip', (c) => { - const db = getDb(); - const id = Number(c.req.param('id')); - - db.prepare( - "UPDATE review_plans SET status = 'skipped', reviewed_at = datetime('now') WHERE item_id = ?" - ).run(id); - - return c.redirect('/review'); -}); - -// ─── Approve all ────────────────────────────────────────────────────────────── - -app.post('/approve-all', (c) => { - const db = getDb(); - - const pending = db.prepare( - "SELECT rp.*, mi.id as item_id FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE rp.status = 'pending' AND rp.is_noop = 0" - ).all() as (ReviewPlan & { item_id: number })[]; - - for (const plan of pending) { - db.prepare( - "UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?" - ).run(plan.id); - - const { item, streams, decisions } = loadItemDetail(db, plan.item_id); - if (item) { - const command = buildCommand(item, streams, decisions); - db.prepare( - "INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')" - ).run(plan.item_id, command); - } - } - - return c.redirect('/review'); -}); - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -function loadItemDetail(db: ReturnType, itemId: number) { - const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(itemId) as MediaItem | undefined; - if (!item) return { item: null, streams: [], plan: null, decisions: [], command: null }; - - const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(itemId) as MediaStream[]; - const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(itemId) as ReviewPlan | undefined | null; - const decisions = plan - ? db.prepare('SELECT * FROM stream_decisions WHERE plan_id = ?').all(plan.id) as StreamDecision[] - : []; - - const command = plan && !plan.is_noop && decisions.some((d) => d.action === 'remove') - ? buildCommand(item, streams, decisions) - : null; - - return { item, streams, plan: plan ?? null, decisions, command }; -} - -function reanalyze(db: ReturnType, itemId: number): void { - const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(itemId) as MediaItem; - if (!item) return; - - const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(itemId) as MediaStream[]; - const subtitleLanguages = getSubtitleLanguages(); - const analysis = analyzeItem( - { original_language: item.original_language, needs_review: item.needs_review }, - streams, - { subtitleLanguages } - ); - - // Upsert plan - db.prepare(` - INSERT INTO review_plans (item_id, status, is_noop, notes) - VALUES (?, 'pending', ?, ?) - ON CONFLICT(item_id) DO UPDATE SET - status = 'pending', - is_noop = excluded.is_noop, - notes = excluded.notes - `).run(itemId, analysis.is_noop ? 1 : 0, analysis.notes); - - const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number }; - - // Replace decisions - db.prepare('DELETE FROM stream_decisions WHERE plan_id = ?').run(plan.id); - for (const dec of analysis.decisions) { - db.prepare( - 'INSERT INTO stream_decisions (plan_id, stream_id, action, target_index) VALUES (?, ?, ?, ?)' - ).run(plan.id, dec.stream_id, dec.action, dec.target_index); - } -} - -export default app; diff --git a/src/api/setup.tsx b/src/api/setup.tsx deleted file mode 100644 index 47df1d8..0000000 --- a/src/api/setup.tsx +++ /dev/null @@ -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(); -}); - -app.post('/jellyfin', async (c) => { - const body = await c.req.formData(); - const url = (body.get('url') as string)?.replace(/\/$/, ''); - const apiKey = body.get('api_key') as string; - - if (!url || !apiKey) { - return c.html(); - } - - const result = await testJellyfin({ url, apiKey, userId: '' }); - if (!result.ok) { - return c.html(); - } - - // Auto-discover user ID - let userId = ''; - try { - const users = await getUsers({ url, apiKey }); - const admin = users.find((u) => u.Name === 'admin') ?? users[0]; - userId = admin?.Id ?? ''; - } catch { - // Non-fatal; user can enter manually later - } - - setConfig('jellyfin_url', url); - setConfig('jellyfin_api_key', apiKey); - if (userId) setConfig('jellyfin_user_id', userId); - - return c.html(); -}); - -app.post('/radarr', async (c) => { - const body = await c.req.formData(); - const url = (body.get('url') as string)?.replace(/\/$/, ''); - const apiKey = body.get('api_key') as string; - - if (!url || !apiKey) { - // Skip was clicked with empty fields — go to next step - return c.redirect('/setup?step=3'); - } - - const result = await testRadarr({ url, apiKey }); - if (!result.ok) { - return c.html(); - } - - setConfig('radarr_url', url); - setConfig('radarr_api_key', apiKey); - setConfig('radarr_enabled', '1'); - - return c.html(); -}); - -app.post('/sonarr', async (c) => { - const body = await c.req.formData(); - const url = (body.get('url') as string)?.replace(/\/$/, ''); - const apiKey = body.get('api_key') as string; - - if (!url || !apiKey) { - return c.redirect('/setup?step=4'); - } - - const result = await testSonarr({ url, apiKey }); - if (!result.ok) { - return c.html(); - } - - setConfig('sonarr_url', url); - setConfig('sonarr_api_key', apiKey); - setConfig('sonarr_enabled', '1'); - - return c.html(); -}); - -app.post('/complete', async (c) => { - const body = await c.req.formData(); - const langs = body.getAll('subtitle_lang') as string[]; - if (langs.length > 0) { - setConfig('subtitle_languages', JSON.stringify(langs)); - } - setConfig('setup_complete', '1'); - return c.redirect('/'); -}); - -export default app; diff --git a/src/db/index.ts b/src/db/index.ts deleted file mode 100644 index 7a8da30..0000000 --- a/src/db/index.ts +++ /dev/null @@ -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 { - const rows = getDb() - .prepare('SELECT key, value FROM config') - .all() as { key: string; value: string }[]; - return Object.fromEntries(rows.map((r) => [r.key, r.value ?? ''])); -} diff --git a/src/features/dashboard/DashboardPage.tsx b/src/features/dashboard/DashboardPage.tsx new file mode 100644 index 0000000..4233a49 --- /dev/null +++ b/src/features/dashboard/DashboardPage.tsx @@ -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 ( +
+
+ {value.toLocaleString()} +
+
{label}
+
+ ); +} + +export function DashboardPage() { + const navigate = useNavigate(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [starting, setStarting] = useState(false); + + useEffect(() => { + api.get('/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
Loading…
; + if (!data) return Failed to load dashboard.; + + const { stats, scanRunning } = data; + + return ( +
+
+

Dashboard

+
+ +
+ + + + + + + {stats.errors > 0 && } +
+ +
+ {scanRunning ? ( + + ⏳ Scan running… + + ) : ( + + )} + + Review changes + + + Execute jobs + +
+ + {stats.scanned === 0 && ( + + Library not scanned yet. Click Start Scan to begin. + + )} +
+ ); +} diff --git a/src/features/execute/ExecutePage.tsx b/src/features/execute/ExecutePage.tsx new file mode 100644 index 0000000..709ec0c --- /dev/null +++ b/src/features/execute/ExecutePage.tsx @@ -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(null); + const [logs, setLogs] = useState>(new Map()); + const [logVisible, setLogVisible] = useState>(new Set()); + const esRef = useRef(null); + + const load = () => api.get('/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
Loading…
; + + 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 ( +
+
+

Execute Jobs

+
+ +
+ {pending} pending + {running > 0 && {running} running} + {done > 0 && {done} done} + {errors > 0 && {errors} error(s)} +
+ +
+ {pending > 0 && } + {jobs.length === 0 && ( +

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

+ )} +
+ + {jobs.length > 0 && ( + + + + {['#', 'Item', 'Command', 'Node', 'Status', 'Actions'].map((h) => ( + + ))} + + + + {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 ( + <> + + + + + + + + + {jobLog && ( + + + + )} + + ); + })} + +
{h}
{job.id} +
{name}
+ {item && ( +
+ ← Details +
+ )} + {item?.file_path && ( +
+ {item.file_path.split('/').pop()} +
+ )} +
+ {cmdShort} + + {job.status === 'pending' ? ( + + ) : ( + {node?.name ?? 'Local'} + )} + + {job.status} + {job.exit_code != null && job.exit_code !== 0 && exit {job.exit_code}} + +
+ {job.status === 'pending' && ( + <> + + + + )} + {(job.status === 'done' || job.status === 'error') && jobLog && ( + + )} +
+
+ {showLog && ( +
+ {jobLog} +
+ )} +
+ )} +
+ ); +} diff --git a/src/features/nodes/NodesPage.tsx b/src/features/nodes/NodesPage.tsx new file mode 100644 index 0000000..64f2481 --- /dev/null +++ b/src/features/nodes/NodesPage.tsx @@ -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([]); + const [error, setError] = useState(''); + const [testing, setTesting] = useState>(new Set()); + const fileRef = useRef(null); + + const load = () => api.get('/api/nodes').then((d) => setNodes(d.nodes)); + useEffect(() => { load(); }, []); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + const form = e.currentTarget; + const fd = new FormData(form); + const result = await api.postForm('/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 ( +
+
+

Remote Nodes

+
+ +

+ 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. +

+ + {/* Add form */} +
+
Add Node
+ {error && {error}} +
+
+ + + + + + +
+ + +
+
+ + {/* Node list */} + {nodes.length === 0 ? ( +

No nodes configured. Add one above.

+ ) : ( + + + + {['Name', 'Host', 'Port', 'User', 'FFmpeg', 'Status', 'Actions'].map((h) => ( + + ))} + + + + {nodes.map((node) => ( + + + + + + + + + + ))} + +
{h}
{node.name}{node.host}{node.port}{node.username}{node.ffmpeg_path} + {node.status} + +
+ + +
+
+ )} +
+ ); +} + +import type React from 'react'; diff --git a/src/features/review/AudioDetailPage.tsx b/src/features/review/AudioDetailPage.tsx new file mode 100644 index 0000000..27bb6c2 --- /dev/null +++ b/src/features/review/AudioDetailPage.tsx @@ -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 = { Video: 0, Audio: 1, Subtitle: 2, Data: 3, EmbeddedImage: 4 }; + +function computeOutIdx(streams: MediaStream[], decisions: StreamDecision[]): Map { + 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(); + 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(`/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(`/api/review/${item.id}/stream/${streamId}/title`, { title }); + onUpdate(d); + }; + + return ( + + + + {['Out', 'Codec', 'Language', 'Title / Info', 'Flags', 'Action'].map((h) => ( + + ))} + + + + {STREAM_SECTIONS.flatMap(({ type, label }) => { + const group = streams.filter((s) => s.type === type); + if (group.length === 0) return []; + return [ + + + , + ...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 ( + + + + + + + + + ); + }), + ]; + })} + +
{h}
+ {label} +
+ {isSub ? : outputNum !== undefined ? outputNum : } + {s.codec ?? '—'} + {lang} {s.language ? ({s.language}) : null} + + {isEditable ? ( + updateTitle(s.id, v)} + /> + ) : ( + {lbl || '—'} + )} + {isEditable && origTitle && origTitle !== lbl && ( +
orig: {origTitle}
+ )} +
+ + {s.is_default ? default : null} + {s.is_forced ? forced : null} + {s.is_hearing_impaired ? CC : null} + + + {isSub ? ( + + ↑ Extract + + ) : plan?.status === 'pending' && (isAudio) ? ( + + ) : ( + {action} + )} +
+ ); +} + +function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) { + const [localVal, setLocalVal] = useState(value); + useEffect(() => { setLocalVal(value); }, [value]); + return ( + 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(null); + const [loading, setLoading] = useState(true); + const [rescanning, setRescanning] = useState(false); + + const load = () => api.get(`/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(`/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(`/api/review/${id}/rescan`); setData(d); } + finally { setRescanning(false); } + }; + + if (loading) return
Loading…
; + if (!data) return Item not found.; + + const { item, plan, command, dockerCommand, dockerMountDir } = data; + const statusKey = plan?.is_noop ? 'noop' : (plan?.status ?? 'pending'); + + return ( +
+
+

+ ← Audio + {item.name} +

+
+ +
+ {/* Meta */} +
+ {[ + { 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: {statusKey} }, + ].map((entry, i) => ( +
+
{entry.label}
+
{entry.value}
+
+ ))} +
+ +
{item.file_path}
+ + {/* Warnings */} + {plan?.notes && {plan.notes}} + {item.needs_review && !item.original_language && ( + + Original language unknown — audio tracks will NOT be filtered until you set it below. + + )} + + {/* Language override */} +
+ + + {item.orig_lang_source && {item.orig_lang_source}} +
+ + {/* Stream table */} + + + {/* FFmpeg command */} + {command && ( +
+
FFmpeg command (audio + subtitle extraction)
+ -
- )} - - {/* Approve / skip */} - {plan?.status === 'pending' && !plan.is_noop && ( -
-
- -
-
- -
-
- )} - {plan?.is_noop ? ( -
- This file is already clean — no changes needed. -
- ) : null} -
- ); -}; - -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`; -} diff --git a/src/views/scan.tsx b/src/views/scan.tsx deleted file mode 100644 index d4f6a39..0000000 --- a/src/views/scan.tsx +++ /dev/null @@ -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 = ({ running, progress, recentItems }) => { - const pct = progress.total > 0 ? Math.round((progress.scanned / progress.total) * 100) : 0; - return ( - - - -
- -
- - {running && ( -