restructure to react spa + hono api, fix missing server/ and lib/
rewrite from monolithic hono jsx to react 19 spa with tanstack router + hono json api backend. add scan, review, execute, nodes, and setup pages. multi-stage dockerfile (node for vite build, bun for runtime). previously, server/ and src/shared/lib/ were silently excluded by global gitignore patterns (/server/ from emacs, lib/ from python). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
41
.env.example
Normal file
41
.env.example
Normal file
@@ -0,0 +1,41 @@
|
||||
# netfelix-audio-fix — environment configuration
|
||||
# Copy this file to .env and fill in your values.
|
||||
# Bun auto-loads .env on every start; .env.development/.env.test/.env.production
|
||||
# are loaded additionally for the matching NODE_ENV.
|
||||
#
|
||||
# When JELLYFIN_URL + JELLYFIN_API_KEY are set, the setup wizard is skipped.
|
||||
# JELLYFIN_USER_ID is optional — omit it when using an admin API key (uses /Items directly).
|
||||
|
||||
# ── Server ────────────────────────────────────────────────────────────────────
|
||||
PORT=3000
|
||||
DATA_DIR=./data
|
||||
|
||||
# ── Jellyfin ──────────────────────────────────────────────────────────────────
|
||||
JELLYFIN_URL=http://jellyfin.local:8096
|
||||
JELLYFIN_API_KEY=your-jellyfin-api-key
|
||||
# JELLYFIN_USER_ID= # optional; omit when using an admin API key
|
||||
|
||||
# ── Radarr (optional) ─────────────────────────────────────────────────────────
|
||||
RADARR_URL=http://radarr.local:7878
|
||||
RADARR_API_KEY=your-radarr-api-key
|
||||
RADARR_ENABLED=false
|
||||
|
||||
# ── Sonarr (optional) ─────────────────────────────────────────────────────────
|
||||
SONARR_URL=http://sonarr.local:8989
|
||||
SONARR_API_KEY=your-sonarr-api-key
|
||||
SONARR_ENABLED=false
|
||||
|
||||
# ── Subtitle languages ────────────────────────────────────────────────────────
|
||||
# Comma-separated ISO 639-2 codes to keep. Forced and hearing-impaired tracks
|
||||
# are always kept regardless of this setting.
|
||||
SUBTITLE_LANGUAGES=eng,deu,spa
|
||||
|
||||
# ── Media paths (optional) ────────────────────────────────────────────────────
|
||||
# Host-side paths for your media libraries. Used to generate Docker commands on
|
||||
# the review page. Jellyfin always exposes libraries at /movies and /series, so
|
||||
# these are the left-hand sides of the Docker volume mounts:
|
||||
# -v /mnt/user/storage/Movies:/movies → MOVIES_PATH=/mnt/user/storage/Movies
|
||||
# -v /mnt/user/storage/Series:/series → SERIES_PATH=/mnt/user/storage/Series
|
||||
#
|
||||
# MOVIES_PATH=/mnt/user/storage/Movies
|
||||
# SERIES_PATH=/mnt/user/storage/Series
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -4,3 +4,9 @@ data/*.db-shm
|
||||
data/*.db-wal
|
||||
bun.lockb
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.test
|
||||
.env.production
|
||||
dist/
|
||||
src/routeTree.gen.ts
|
||||
|
||||
253
AGENTS.md
Normal file
253
AGENTS.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# AGENTS.md — PWA Stack Reference
|
||||
|
||||
This file is the authoritative guide for AI agents (Claude Code, Cursor, Copilot, etc.) working on any app in this repository. **Always follow this stack. Never introduce dependencies outside of it without explicit approval.**
|
||||
|
||||
---
|
||||
|
||||
## Stack Overview
|
||||
|
||||
| Layer | Tool | Version |
|
||||
| ----------------- | -------------------------------------- | ------- |
|
||||
| Runtime | Bun | latest |
|
||||
| Build | Vite | latest |
|
||||
| UI Framework | React | 19+ |
|
||||
| Component Library | shadcn/ui + Tailwind CSS v4 | latest |
|
||||
| Routing | TanStack Router | latest |
|
||||
| State Management | Zustand | latest |
|
||||
| Local Database | PGlite (PostgreSQL in WASM) | latest |
|
||||
| Forms | TanStack Form + Zod | latest |
|
||||
| Animations | CSS View Transitions API | native |
|
||||
| PWA | vite-plugin-pwa + Workbox | latest |
|
||||
| Testing (unit) | Vitest + Testing Library | latest |
|
||||
| Testing (e2e) | Playwright | latest |
|
||||
| Code Quality | Biome + lint-staged + simple-git-hooks | latest |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── features/ # One folder per domain feature
|
||||
│ ├── auth/
|
||||
│ │ ├── components/ # UI components for this feature
|
||||
│ │ ├── hooks/ # Custom hooks
|
||||
│ │ ├── store.ts # Zustand store (if needed)
|
||||
│ │ ├── schema.ts # Zod schemas for this feature
|
||||
│ │ └── index.ts # Public API of the feature
|
||||
│ └── [feature-name]/
|
||||
│ └── ...
|
||||
├── shared/
|
||||
│ ├── components/ # Generic reusable UI (Button, Modal, etc.)
|
||||
│ ├── hooks/ # Generic hooks (useMediaQuery, etc.)
|
||||
│ ├── db/
|
||||
│ │ ├── client.ts # PGlite instance (singleton)
|
||||
│ │ ├── migrations/ # SQL migration files (001_init.sql, etc.)
|
||||
│ │ └── schema.ts # Zod schemas mirroring DB tables
|
||||
│ └── lib/ # Pure utility functions
|
||||
├── routes/ # TanStack Router route files
|
||||
│ ├── __root.tsx # Root layout
|
||||
│ ├── index.tsx # / route
|
||||
│ └── [feature]/
|
||||
│ └── index.tsx
|
||||
├── app.tsx # App entry, router provider
|
||||
└── main.tsx # Bun/Vite entry point
|
||||
public/
|
||||
├── manifest.webmanifest # PWA manifest
|
||||
└── icons/ # PWA icons (all sizes)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rules by Layer
|
||||
|
||||
### React & TypeScript
|
||||
|
||||
- Use **React 19** features: use(), Suspense, transitions.
|
||||
- All files are `.tsx` or `.ts`. No `.js` or `.jsx`.
|
||||
- Prefer named exports over default exports (except route files — TanStack Router requires default exports).
|
||||
- No `any`. Use `unknown` and narrow with Zod when type is uncertain.
|
||||
- Use `satisfies` operator for type-safe object literals.
|
||||
|
||||
### Styling (Tailwind + shadcn/ui)
|
||||
|
||||
- **Never write custom CSS files.** Use Tailwind utility classes exclusively.
|
||||
- Use `cn()` (from `src/shared/lib/utils.ts`) for conditional class merging.
|
||||
- shadcn/ui components live in `src/shared/components/ui/` — **never modify them directly**. Wrap them instead.
|
||||
- Dark mode via Tailwind's `dark:` variant. The `class` strategy is used (not `media`).
|
||||
- Responsive design: mobile-first. `sm:`, `md:`, `lg:` for larger screens.
|
||||
- For "native app" feel on mobile: use `touch-action: manipulation` on interactive elements, remove tap highlight with `[-webkit-tap-highlight-color:transparent]`.
|
||||
|
||||
### Routing (TanStack Router)
|
||||
|
||||
- Use **file-based routing** via `@tanstack/router-plugin/vite`.
|
||||
- Route files live in `src/routes/`. Each file exports a `Route` created with `createFileRoute`.
|
||||
- **Always use `Link` and `useNavigate`** from TanStack Router. Never `<a href>` for internal navigation.
|
||||
- Search params are typed via Zod — define `validateSearch` on every route that uses search params.
|
||||
- Loaders (`loader` option on route) are the correct place for data fetching that should block navigation.
|
||||
|
||||
```ts
|
||||
// Example route with typed search params
|
||||
export const Route = createFileRoute("/runs/")({
|
||||
validateSearch: z.object({
|
||||
page: z.number().default(1),
|
||||
filter: z.enum(["all", "recent"]).default("all"),
|
||||
}),
|
||||
component: RunsPage,
|
||||
});
|
||||
```
|
||||
|
||||
### Database (PGlite)
|
||||
|
||||
- The PGlite instance is a **singleton** exported from `src/shared/db/client.ts`.
|
||||
- **Never import PGlite directly in components.** Always go through a hook or a feature's data layer.
|
||||
- Use **numbered SQL migration files**: `src/shared/db/migrations/001_init.sql`, `002_add_segments.sql`, etc. Run them in order on app start.
|
||||
- Zod schemas in `src/shared/db/schema.ts` mirror every DB table. Use them to validate data coming out of PGlite.
|
||||
- For reactive UI updates from DB changes, use a thin wrapper with Zustand's `subscribeWithSelector` or re-query on relevant user actions.
|
||||
|
||||
```ts
|
||||
// src/shared/db/client.ts
|
||||
import { PGlite } from "@electric-sql/pglite";
|
||||
|
||||
let _db: PGlite | null = null;
|
||||
|
||||
export async function getDb(): Promise<PGlite> {
|
||||
if (!_db) {
|
||||
_db = new PGlite("idb://myapp");
|
||||
await runMigrations(_db);
|
||||
}
|
||||
return _db;
|
||||
}
|
||||
```
|
||||
|
||||
### State Management (Zustand)
|
||||
|
||||
- Zustand is for **ephemeral UI state only**: modals, active tabs, current user session, UI preferences.
|
||||
- **Persistent data lives in PGlite, not Zustand.** Do not use `zustand/middleware` persist for app data.
|
||||
- One store file per feature: `src/features/auth/store.ts`.
|
||||
- Use `subscribeWithSelector` middleware when components need to subscribe to slices.
|
||||
- Keep stores flat. Avoid deeply nested state.
|
||||
|
||||
```ts
|
||||
// Example store
|
||||
import { create } from "zustand";
|
||||
|
||||
interface UIStore {
|
||||
activeTab: string;
|
||||
setActiveTab: (tab: string) => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIStore>((set) => ({
|
||||
activeTab: "home",
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
}));
|
||||
```
|
||||
|
||||
### Forms (TanStack Form + Zod)
|
||||
|
||||
- All forms use `useForm` from `@tanstack/react-form`.
|
||||
- Validation is always done with a Zod schema via the `validators` option.
|
||||
- **Reuse Zod schemas** from `src/shared/db/schema.ts` or `src/features/[x]/schema.ts` — do not duplicate validation logic.
|
||||
- Error messages are shown inline below the field, never in a toast.
|
||||
|
||||
```ts
|
||||
const form = useForm({
|
||||
defaultValues: { name: "" },
|
||||
validators: { onChange: myZodSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
/* ... */
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Animations (CSS View Transitions API)
|
||||
|
||||
- Use the native **View Transitions API** for page transitions. No animation libraries.
|
||||
- Wrap navigation calls with `document.startViewTransition()`.
|
||||
- TanStack Router's `RouterDevtools` and upcoming native support handle this — check the TanStack Router docs for the current recommended integration.
|
||||
- Use `view-transition-name` CSS property on elements that should animate between routes.
|
||||
- Provide a `@media (prefers-reduced-motion: reduce)` fallback that disables transitions.
|
||||
|
||||
```css
|
||||
/* Disable transitions for users who prefer it */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-old(*),
|
||||
::view-transition-new(*) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PWA (vite-plugin-pwa)
|
||||
|
||||
- Config lives in `vite.config.ts` under the `VitePWA()` plugin.
|
||||
- Strategy: `generateSW` (not `injectManifest`) unless custom SW logic is needed.
|
||||
- **Always precache** the PGlite WASM files — without this the app won't work offline.
|
||||
- `manifest.webmanifest` must include: `name`, `short_name`, `start_url: "/"`, `display: "standalone"`, `theme_color`, icons at 192×192 and 512×512.
|
||||
- Register the SW with `registerType: 'autoUpdate'` and show a "New version available" toast.
|
||||
|
||||
### Testing
|
||||
|
||||
**Unit/Component Tests (Vitest)**
|
||||
|
||||
- Test files co-located with source: `src/features/auth/auth.test.ts`.
|
||||
- Use Testing Library for component tests. Query by role, label, or text — never by class or id.
|
||||
- Mock PGlite with an in-memory instance (no `idb://` prefix) in tests.
|
||||
- Run with: `bun test`
|
||||
|
||||
**E2E Tests (Playwright)**
|
||||
|
||||
- Test files in `e2e/`.
|
||||
- Focus on critical user flows: onboarding, data entry, offline behavior, install prompt.
|
||||
- Use `page.evaluate()` to inspect IndexedDB/PGlite state when needed.
|
||||
- Run with: `bun e2e`
|
||||
|
||||
### Code Quality (Biome)
|
||||
|
||||
- Biome handles both **formatting and linting** — no Prettier, no ESLint.
|
||||
- Config in `biome.json` at project root.
|
||||
- `lint-staged` + `simple-git-hooks` run Biome on staged files before every commit.
|
||||
- CI also runs `biome check --apply` — commits that fail Biome are rejected.
|
||||
- **Never disable Biome rules with inline comments** without a code comment explaining why.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun install # Install dependencies
|
||||
bun dev # Start dev server
|
||||
bun build # Production build
|
||||
bun preview # Preview production build locally
|
||||
bun test # Run Vitest unit tests
|
||||
bun e2e # Run Playwright E2E tests
|
||||
bun lint # Run Biome linter
|
||||
bun format # Run Biome formatter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Do Not
|
||||
|
||||
- ❌ Do not use `npm` or `yarn` — always use `bun`
|
||||
- ❌ Do not add ESLint, Prettier, or Husky — Biome + simple-git-hooks covers this
|
||||
- ❌ Do not use `react-query` or `swr` — data comes from PGlite, not a remote API
|
||||
- ❌ Do not store persistent app data in Zustand — use PGlite
|
||||
- ❌ Do not use Framer Motion / React Spring — use CSS View Transitions
|
||||
- ❌ Do not use `<a href>` for internal links — use TanStack Router's `<Link>`
|
||||
- ❌ Do not write raw CSS files — use Tailwind utilities
|
||||
- ❌ Do not modify files in `src/shared/components/ui/` — wrap shadcn components instead
|
||||
- ❌ Do not introduce a new dependency without checking if the existing stack already covers the need
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Feature Checklist
|
||||
|
||||
1. Create `src/features/[name]/` folder
|
||||
2. Add Zod schema in `src/features/[name]/schema.ts`
|
||||
3. Add DB migration in `src/shared/db/migrations/` if needed
|
||||
4. Add route file in `src/routes/[name]/index.tsx`
|
||||
5. Add Zustand store in `src/features/[name]/store.ts` if UI state is needed
|
||||
6. Write unit tests co-located with the feature
|
||||
7. Write at least one Playwright E2E test for the happy path
|
||||
8. Run `bun lint` and `bun test` before committing
|
||||
19
Dockerfile
19
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"]
|
||||
|
||||
22
biome.json
Normal file
22
biome.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
|
||||
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
|
||||
"organizeImports": { "enabled": true },
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"indentWidth": 1,
|
||||
"lineWidth": 120
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": { "noExplicitAny": "off" },
|
||||
"style": { "noNonNullAssertion": "off" }
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"ignore": ["node_modules", "dist", "src/routeTree.gen.ts"]
|
||||
}
|
||||
}
|
||||
533
bun.lock
533
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=="],
|
||||
}
|
||||
}
|
||||
|
||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>netfelix-audio-fix</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
31
package.json
31
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"
|
||||
}
|
||||
}
|
||||
|
||||
389
public/app.css
389
public/app.css
@@ -1,389 +0,0 @@
|
||||
/* ─── Base overrides ──────────────────────────────────────────────────────── */
|
||||
:root {
|
||||
--nav-height: 3.5rem;
|
||||
--color-keep: #2d9a5f;
|
||||
--color-remove: #c0392b;
|
||||
--color-pending: #888;
|
||||
--color-approved: #2d9a5f;
|
||||
--color-skipped: #888;
|
||||
--color-done: #2d9a5f;
|
||||
--color-error: #c0392b;
|
||||
--color-noop: #555;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Mono', 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ─── Nav ─────────────────────────────────────────────────────────────────── */
|
||||
.app-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0 1.5rem;
|
||||
height: var(--nav-height);
|
||||
background: var(--pico-background-color);
|
||||
border-bottom: 1px solid var(--pico-muted-border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.app-nav .brand {
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
margin-right: 1.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--pico-color);
|
||||
}
|
||||
|
||||
.app-nav a {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
color: var(--pico-muted-color);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.app-nav a:hover,
|
||||
.app-nav a.active {
|
||||
background: var(--pico-secondary-background);
|
||||
color: var(--pico-color);
|
||||
}
|
||||
|
||||
.app-nav .spacer { flex: 1; }
|
||||
|
||||
/* ─── Layout ──────────────────────────────────────────────────────────────── */
|
||||
.page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1.5rem 3rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* ─── Stat cards ──────────────────────────────────────────────────────────── */
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border: 1px solid var(--pico-muted-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card .num {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-card .label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--pico-muted-color);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* ─── Badges / status ─────────────────────────────────────────────────────── */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
padding: 0.15em 0.55em;
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: var(--pico-secondary-background);
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
|
||||
.badge-keep { background: #d4edda; color: #155724; }
|
||||
.badge-remove { background: #f8d7da; color: #721c24; }
|
||||
.badge-pending { background: #e2e3e5; color: #383d41; }
|
||||
.badge-approved{ background: #d4edda; color: #155724; }
|
||||
.badge-skipped { background: #e2e3e5; color: #383d41; }
|
||||
.badge-done { background: #d1ecf1; color: #0c5460; }
|
||||
.badge-error { background: #f8d7da; color: #721c24; }
|
||||
.badge-noop { background: #e2e3e5; color: #383d41; }
|
||||
.badge-running { background: #fff3cd; color: #856404; }
|
||||
.badge-manual { background: #fde8c8; color: #7d4400; }
|
||||
|
||||
/* ─── Filter tabs ─────────────────────────────────────────────────────────── */
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.filter-tabs a,
|
||||
.filter-tabs button {
|
||||
padding: 0.35rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--pico-muted-border-color);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
|
||||
.filter-tabs a.active,
|
||||
.filter-tabs button.active {
|
||||
background: var(--pico-primary);
|
||||
border-color: var(--pico-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ─── Tables ──────────────────────────────────────────────────────────────── */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--pico-muted-color);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 2px solid var(--pico-muted-border-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid var(--pico-muted-border-color);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.data-table tr:hover td {
|
||||
background: var(--pico-secondary-background);
|
||||
}
|
||||
|
||||
.data-table tr.expanded td {
|
||||
background: var(--pico-secondary-background);
|
||||
}
|
||||
|
||||
.data-table td.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* ─── Stream decision table ───────────────────────────────────────────────── */
|
||||
.stream-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.stream-table th {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--pico-muted-color);
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-bottom: 1px solid var(--pico-muted-border-color);
|
||||
}
|
||||
|
||||
.stream-table td {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-bottom: 1px solid var(--pico-muted-border-color);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.stream-row-keep { background: #f0fff4; }
|
||||
.stream-row-remove { background: #fff5f5; }
|
||||
|
||||
/* ─── Action toggle buttons ───────────────────────────────────────────────── */
|
||||
.toggle-keep,
|
||||
.toggle-remove {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.2em 0.6em;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
min-width: 5rem;
|
||||
}
|
||||
|
||||
.toggle-keep { background: var(--color-keep); color: #fff; }
|
||||
.toggle-remove { background: var(--color-remove); color: #fff; }
|
||||
|
||||
/* ─── Progress bar ────────────────────────────────────────────────────────── */
|
||||
.progress-wrap {
|
||||
background: var(--pico-muted-border-color);
|
||||
border-radius: 999px;
|
||||
height: 0.5rem;
|
||||
overflow: hidden;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: var(--pico-primary);
|
||||
border-radius: 999px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* ─── Log output ──────────────────────────────────────────────────────────── */
|
||||
.log-output {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
background: #1a1a1a;
|
||||
color: #d4d4d4;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ─── Command preview ─────────────────────────────────────────────────────── */
|
||||
.command-preview {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
background: #1a1a1a;
|
||||
color: #9cdcfe;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
border: none;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
/* ─── Detail panel ────────────────────────────────────────────────────────── */
|
||||
.detail-panel {
|
||||
border: 1px solid var(--pico-muted-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.detail-meta dt {
|
||||
color: var(--pico-muted-color);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.detail-meta dd {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── Setup wizard ────────────────────────────────────────────────────────── */
|
||||
.wizard-steps {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 2px solid var(--pico-muted-border-color);
|
||||
}
|
||||
|
||||
.wizard-step {
|
||||
padding: 0.6rem 1.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--pico-muted-color);
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
.wizard-step.active {
|
||||
color: var(--pico-primary);
|
||||
border-bottom-color: var(--pico-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wizard-step.done {
|
||||
color: var(--color-keep);
|
||||
}
|
||||
|
||||
/* ─── Connection status ───────────────────────────────────────────────────── */
|
||||
.conn-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.3em 0.7em;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.conn-status.ok { background: #d4edda; color: #155724; }
|
||||
.conn-status.error { background: #f8d7da; color: #721c24; }
|
||||
.conn-status.checking { background: #fff3cd; color: #856404; }
|
||||
|
||||
/* ─── Inline lang select ──────────────────────────────────────────────────── */
|
||||
.lang-select {
|
||||
font-size: 0.82rem;
|
||||
padding: 0.2em 0.5em;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--pico-muted-border-color);
|
||||
background: var(--pico-background-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ─── Alerts ──────────────────────────────────────────────────────────────── */
|
||||
.alert {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.alert-warning { background: #fff3cd; color: #856404; border: 1px solid #ffc107; }
|
||||
.alert-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
|
||||
.alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
||||
.alert-info { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; }
|
||||
|
||||
/* ─── HTMX loading indicator ─────────────────────────────────────────────── */
|
||||
.htmx-indicator { display: none; }
|
||||
.htmx-request .htmx-indicator { display: inline; }
|
||||
.htmx-request.htmx-indicator { display: inline; }
|
||||
|
||||
/* ─── Utility ─────────────────────────────────────────────────────────────── */
|
||||
.muted { color: var(--pico-muted-color); }
|
||||
.mono { font-family: var(--font-mono); font-size: 0.8rem; }
|
||||
.truncate { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.flex-row { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.actions-col { white-space: nowrap; display: flex; gap: 0.4rem; align-items: center; }
|
||||
|
||||
button[data-size="sm"],
|
||||
a[data-size="sm"] {
|
||||
padding: 0.25rem 0.65rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
22
server/api/dashboard.ts
Normal file
22
server/api/dashboard.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Hono } from 'hono';
|
||||
import { getDb, getConfig } from '../db/index';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get('/', (c) => {
|
||||
const db = getDb();
|
||||
|
||||
const totalItems = (db.prepare('SELECT COUNT(*) as n FROM media_items').get() as { n: number }).n;
|
||||
const scanned = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'scanned'").get() as { n: number }).n;
|
||||
const needsAction = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }).n;
|
||||
const noChange = (db.prepare('SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1').get() as { n: number }).n;
|
||||
const approved = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").get() as { n: number }).n;
|
||||
const done = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'done'").get() as { n: number }).n;
|
||||
const errors = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'error'").get() as { n: number }).n;
|
||||
const scanRunning = getConfig('scan_running') === '1';
|
||||
const setupComplete = getConfig('setup_complete') === '1';
|
||||
|
||||
return c.json({ stats: { totalItems, scanned, needsAction, approved, done, errors, noChange }, scanRunning, setupComplete });
|
||||
});
|
||||
|
||||
export default app;
|
||||
228
server/api/execute.ts
Normal file
228
server/api/execute.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { Hono } from 'hono';
|
||||
import { stream } from 'hono/streaming';
|
||||
import { getDb } from '../db/index';
|
||||
import { execStream } from '../services/ssh';
|
||||
import type { Job, Node, MediaItem, MediaStream } from '../types';
|
||||
import { predictExtractedFiles } from '../services/ffmpeg';
|
||||
import { accessSync, constants } from 'node:fs';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// ─── SSE state ────────────────────────────────────────────────────────────────
|
||||
|
||||
const jobListeners = new Set<(data: string) => void>();
|
||||
|
||||
function emitJobUpdate(jobId: number, status: string, output?: string): void {
|
||||
const line = `event: job_update\ndata: ${JSON.stringify({ id: jobId, status, output })}\n\n`;
|
||||
for (const l of jobListeners) l(line);
|
||||
}
|
||||
|
||||
function loadJobRow(jobId: number) {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`
|
||||
SELECT j.*, mi.id as mi_id, mi.name, mi.type, mi.series_name, mi.season_number,
|
||||
mi.episode_number, mi.file_path,
|
||||
n.id as n_id, n.name as node_name, n.host, n.port, n.username,
|
||||
n.private_key, n.ffmpeg_path, n.work_dir, n.status as node_status
|
||||
FROM jobs j
|
||||
LEFT JOIN media_items mi ON mi.id = j.item_id
|
||||
LEFT JOIN nodes n ON n.id = j.node_id
|
||||
WHERE j.id = ?
|
||||
`).get(jobId) as (Job & {
|
||||
mi_id: number | null; name: string | null; type: string | null;
|
||||
series_name: string | null; season_number: number | null; episode_number: number | null;
|
||||
file_path: string | null; n_id: number | null; node_name: string | null;
|
||||
host: string | null; port: number | null; username: string | null;
|
||||
private_key: string | null; ffmpeg_path: string | null; work_dir: string | null; node_status: string | null;
|
||||
}) | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const nodes = db.prepare('SELECT * FROM nodes ORDER BY name').all() as Node[];
|
||||
const item = row.name ? { id: row.item_id, name: row.name, type: row.type, series_name: row.series_name, season_number: row.season_number, episode_number: row.episode_number, file_path: row.file_path } as unknown as MediaItem : null;
|
||||
const node = row.node_name ? { id: row.node_id!, name: row.node_name, host: row.host!, port: row.port!, username: row.username!, private_key: row.private_key!, ffmpeg_path: row.ffmpeg_path!, work_dir: row.work_dir!, status: row.node_status! } as unknown as Node : null;
|
||||
|
||||
return { job: row as unknown as Job, item, node, nodes };
|
||||
}
|
||||
|
||||
// ─── List ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/', (c) => {
|
||||
const db = getDb();
|
||||
const jobRows = db.prepare(`
|
||||
SELECT j.*, mi.name, mi.type, mi.series_name, mi.season_number, mi.episode_number, mi.file_path,
|
||||
n.name as node_name, n.host, n.port, n.username, n.private_key, n.ffmpeg_path, n.work_dir, n.status as node_status
|
||||
FROM jobs j
|
||||
LEFT JOIN media_items mi ON mi.id = j.item_id
|
||||
LEFT JOIN nodes n ON n.id = j.node_id
|
||||
ORDER BY j.created_at DESC LIMIT 200
|
||||
`).all() as (Job & { name: string; type: string; series_name: string | null; season_number: number | null; episode_number: number | null; file_path: string; node_name: string | null; host: string | null; port: number | null; username: string | null; private_key: string | null; ffmpeg_path: string | null; work_dir: string | null; node_status: string | null; })[];
|
||||
|
||||
const jobs = jobRows.map((r) => ({
|
||||
job: r as unknown as Job,
|
||||
item: r.name ? { id: r.item_id, name: r.name, type: r.type, series_name: r.series_name, season_number: r.season_number, episode_number: r.episode_number, file_path: r.file_path } as unknown as MediaItem : null,
|
||||
node: r.node_name ? { id: r.node_id!, name: r.node_name, host: r.host!, port: r.port!, username: r.username!, private_key: r.private_key!, ffmpeg_path: r.ffmpeg_path!, work_dir: r.work_dir!, status: r.node_status! } as unknown as Node : null,
|
||||
}));
|
||||
|
||||
const nodes = db.prepare('SELECT * FROM nodes ORDER BY name').all() as Node[];
|
||||
return c.json({ jobs, nodes });
|
||||
});
|
||||
|
||||
// ─── Start all pending ────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/start', (c) => {
|
||||
const db = getDb();
|
||||
const pending = db.prepare("SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at").all() as Job[];
|
||||
for (const job of pending) runJob(job).catch((err) => console.error(`Job ${job.id} failed:`, err));
|
||||
return c.json({ ok: true, started: pending.length });
|
||||
});
|
||||
|
||||
// ─── Assign node ──────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/job/:id/assign', async (c) => {
|
||||
const db = getDb();
|
||||
const jobId = Number(c.req.param('id'));
|
||||
const body = await c.req.json<{ node_id: number | null }>();
|
||||
db.prepare('UPDATE jobs SET node_id = ? WHERE id = ?').run(body.node_id, jobId);
|
||||
const result = loadJobRow(jobId);
|
||||
if (!result) return c.notFound();
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// ─── Run single ───────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/job/:id/run', async (c) => {
|
||||
const db = getDb();
|
||||
const jobId = Number(c.req.param('id'));
|
||||
const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(jobId) as Job | undefined;
|
||||
if (!job || job.status !== 'pending') {
|
||||
const result = loadJobRow(jobId);
|
||||
if (!result) return c.notFound();
|
||||
return c.json(result);
|
||||
}
|
||||
runJob(job).catch((err) => console.error(`Job ${job.id} failed:`, err));
|
||||
const result = loadJobRow(jobId);
|
||||
if (!result) return c.notFound();
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// ─── Cancel ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/job/:id/cancel', (c) => {
|
||||
const db = getDb();
|
||||
const jobId = Number(c.req.param('id'));
|
||||
db.prepare("DELETE FROM jobs WHERE id = ? AND status = 'pending'").run(jobId);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── SSE ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/events', (c) => {
|
||||
return stream(c, async (s) => {
|
||||
c.header('Content-Type', 'text/event-stream');
|
||||
c.header('Cache-Control', 'no-cache');
|
||||
|
||||
const queue: string[] = [];
|
||||
let resolve: (() => void) | null = null;
|
||||
const listener = (data: string) => { queue.push(data); resolve?.(); };
|
||||
|
||||
jobListeners.add(listener);
|
||||
s.onAbort(() => { jobListeners.delete(listener); });
|
||||
|
||||
try {
|
||||
while (!s.closed) {
|
||||
if (queue.length > 0) {
|
||||
await s.write(queue.shift()!);
|
||||
} else {
|
||||
await new Promise<void>((res) => { resolve = res; setTimeout(res, 15_000); });
|
||||
resolve = null;
|
||||
if (queue.length === 0) await s.write(': keepalive\n\n');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
jobListeners.delete(listener);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Job execution ────────────────────────────────────────────────────────────
|
||||
|
||||
async function runJob(job: Job): Promise<void> {
|
||||
const db = getDb();
|
||||
|
||||
if (!job.node_id) {
|
||||
const itemRow = db.prepare('SELECT file_path FROM media_items WHERE id = ?').get(job.item_id) as { file_path: string } | undefined;
|
||||
if (itemRow?.file_path) {
|
||||
try { accessSync(itemRow.file_path, constants.R_OK | constants.W_OK); } catch (fsErr) {
|
||||
const msg = `File not accessible: ${itemRow.file_path}\n${(fsErr as Error).message}`;
|
||||
db.prepare("UPDATE jobs SET status = 'error', output = ?, exit_code = 1, completed_at = datetime('now') WHERE id = ?").run(msg, job.id);
|
||||
emitJobUpdate(job.id, 'error', msg);
|
||||
db.prepare("UPDATE review_plans SET status = 'error' WHERE item_id = ?").run(job.item_id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare("UPDATE jobs SET status = 'running', started_at = datetime('now'), output = '' WHERE id = ?").run(job.id);
|
||||
emitJobUpdate(job.id, 'running');
|
||||
|
||||
let outputLines: string[] = [];
|
||||
const flush = (final = false) => {
|
||||
const text = outputLines.join('\n');
|
||||
if (final || outputLines.length % 10 === 0) db.prepare('UPDATE jobs SET output = ? WHERE id = ?').run(text, job.id);
|
||||
emitJobUpdate(job.id, 'running', text);
|
||||
};
|
||||
|
||||
try {
|
||||
if (job.node_id) {
|
||||
const node = db.prepare('SELECT * FROM nodes WHERE id = ?').get(job.node_id) as Node | undefined;
|
||||
if (!node) throw new Error(`Node ${job.node_id} not found`);
|
||||
for await (const line of execStream(node, job.command)) { outputLines.push(line); flush(); }
|
||||
} else {
|
||||
const proc = Bun.spawn(['sh', '-c', job.command], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const readStream = async (readable: ReadableStream<Uint8Array>, prefix = '') => {
|
||||
const reader = readable.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const text = decoder.decode(value);
|
||||
const lines = text.split('\n').filter((l) => l.trim());
|
||||
for (const line of lines) outputLines.push(prefix + line);
|
||||
flush();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
await Promise.all([readStream(proc.stdout), readStream(proc.stderr, '[stderr] '), proc.exited]);
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) throw new Error(`FFmpeg exited with code ${exitCode}`);
|
||||
}
|
||||
|
||||
const fullOutput = outputLines.join('\n');
|
||||
db.prepare("UPDATE jobs SET status = 'done', exit_code = 0, output = ?, completed_at = datetime('now') WHERE id = ?").run(fullOutput, job.id);
|
||||
emitJobUpdate(job.id, 'done', fullOutput);
|
||||
db.prepare("UPDATE review_plans SET status = 'done' WHERE item_id = ?").run(job.item_id);
|
||||
|
||||
// Populate subtitle_files table with extracted sidecar files
|
||||
try {
|
||||
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(job.item_id) as MediaItem | undefined;
|
||||
const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ?').all(job.item_id) as MediaStream[];
|
||||
if (item && streams.length > 0) {
|
||||
const files = predictExtractedFiles(item, streams);
|
||||
const insertFile = db.prepare('INSERT OR IGNORE INTO subtitle_files (item_id, file_path, language, codec, is_forced, is_hearing_impaired) VALUES (?, ?, ?, ?, ?, ?)');
|
||||
for (const f of files) {
|
||||
insertFile.run(job.item_id, f.file_path, f.language, f.codec, f.is_forced ? 1 : 0, f.is_hearing_impaired ? 1 : 0);
|
||||
}
|
||||
db.prepare('UPDATE review_plans SET subs_extracted = 1 WHERE item_id = ?').run(job.item_id);
|
||||
}
|
||||
} catch (subErr) { console.error('Failed to record extracted subtitle files:', subErr); }
|
||||
} catch (err) {
|
||||
const fullOutput = outputLines.join('\n') + '\n' + String(err);
|
||||
db.prepare("UPDATE jobs SET status = 'error', exit_code = 1, output = ?, completed_at = datetime('now') WHERE id = ?").run(fullOutput, job.id);
|
||||
emitJobUpdate(job.id, 'error', fullOutput);
|
||||
db.prepare("UPDATE review_plans SET status = 'error' WHERE item_id = ?").run(job.item_id);
|
||||
}
|
||||
}
|
||||
|
||||
export default app;
|
||||
74
server/api/nodes.ts
Normal file
74
server/api/nodes.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Hono } from 'hono';
|
||||
import { getDb } from '../db/index';
|
||||
import { testConnection } from '../services/ssh';
|
||||
import type { Node } from '../types';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get('/', (c) => {
|
||||
const db = getDb();
|
||||
const nodes = db.prepare('SELECT * FROM nodes ORDER BY name').all() as Node[];
|
||||
return c.json({ nodes });
|
||||
});
|
||||
|
||||
app.post('/', async (c) => {
|
||||
const db = getDb();
|
||||
const contentType = c.req.header('Content-Type') ?? '';
|
||||
let name: string, host: string, port: number, username: string, ffmpegPath: string, workDir: string, privateKey: string;
|
||||
|
||||
// Support both multipart (file upload) and JSON
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
const body = await c.req.formData();
|
||||
name = body.get('name') as string;
|
||||
host = body.get('host') as string;
|
||||
port = Number(body.get('port') ?? '22');
|
||||
username = body.get('username') as string;
|
||||
ffmpegPath = (body.get('ffmpeg_path') as string) || 'ffmpeg';
|
||||
workDir = (body.get('work_dir') as string) || '/tmp';
|
||||
const keyFile = body.get('private_key') as File | null;
|
||||
if (!name || !host || !username || !keyFile) return c.json({ ok: false, error: 'All fields are required' }, 400);
|
||||
privateKey = await keyFile.text();
|
||||
} else {
|
||||
const body = await c.req.json<{ name: string; host: string; port?: number; username: string; ffmpeg_path?: string; work_dir?: string; private_key: string }>();
|
||||
name = body.name; host = body.host; port = body.port ?? 22; username = body.username;
|
||||
ffmpegPath = body.ffmpeg_path || 'ffmpeg'; workDir = body.work_dir || '/tmp'; privateKey = body.private_key;
|
||||
if (!name || !host || !username || !privateKey) return c.json({ ok: false, error: 'All fields are required' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare('INSERT INTO nodes (name, host, port, username, private_key, ffmpeg_path, work_dir) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(name, host, port, username, privateKey, ffmpegPath, workDir);
|
||||
} catch (e) {
|
||||
if (String(e).includes('UNIQUE')) return c.json({ ok: false, error: `A node named "${name}" already exists` }, 409);
|
||||
throw e;
|
||||
}
|
||||
|
||||
const nodes = db.prepare('SELECT * FROM nodes ORDER BY name').all() as Node[];
|
||||
return c.json({ ok: true, nodes });
|
||||
});
|
||||
|
||||
app.delete('/:id', (c) => {
|
||||
const db = getDb();
|
||||
db.prepare('DELETE FROM nodes WHERE id = ?').run(Number(c.req.param('id')));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// Legacy POST delete for HTML-form compat (may be removed later)
|
||||
app.post('/:id/delete', (c) => {
|
||||
const db = getDb();
|
||||
db.prepare('DELETE FROM nodes WHERE id = ?').run(Number(c.req.param('id')));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/:id/test', async (c) => {
|
||||
const db = getDb();
|
||||
const id = Number(c.req.param('id'));
|
||||
const node = db.prepare('SELECT * FROM nodes WHERE id = ?').get(id) as Node | undefined;
|
||||
if (!node) return c.notFound();
|
||||
const result = await testConnection(node);
|
||||
const status = result.ok ? 'ok' : `error: ${result.error}`;
|
||||
db.prepare("UPDATE nodes SET status = ?, last_checked_at = datetime('now') WHERE id = ?").run(status, id);
|
||||
return c.json({ ok: result.ok, status, error: result.error });
|
||||
});
|
||||
|
||||
export default app;
|
||||
372
server/api/review.ts
Normal file
372
server/api/review.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import { Hono } from 'hono';
|
||||
import { getDb, getConfig, getAllConfig } from '../db/index';
|
||||
import { analyzeItem } from '../services/analyzer';
|
||||
import { buildCommand, buildDockerCommand } from '../services/ffmpeg';
|
||||
import { normalizeLanguage, getItem, refreshItem, mapStream } from '../services/jellyfin';
|
||||
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '../types';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function getSubtitleLanguages(): string[] {
|
||||
return JSON.parse(getConfig('subtitle_languages') ?? '["eng","deu","spa"]');
|
||||
}
|
||||
|
||||
function countsByFilter(db: ReturnType<typeof getDb>): Record<string, number> {
|
||||
const total = (db.prepare('SELECT COUNT(*) as n FROM review_plans').get() as { n: number }).n;
|
||||
const noops = (db.prepare('SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1').get() as { n: number }).n;
|
||||
const pending = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }).n;
|
||||
const approved = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").get() as { n: number }).n;
|
||||
const skipped = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'skipped'").get() as { n: number }).n;
|
||||
const done = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'done'").get() as { n: number }).n;
|
||||
const error = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'error'").get() as { n: number }).n;
|
||||
const manual = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE needs_review = 1 AND original_language IS NULL").get() as { n: number }).n;
|
||||
return { all: total, needs_action: pending, noop: noops, approved, skipped, done, error, manual };
|
||||
}
|
||||
|
||||
function buildWhereClause(filter: string): string {
|
||||
switch (filter) {
|
||||
case 'needs_action': return "rp.status = 'pending' AND rp.is_noop = 0";
|
||||
case 'noop': return 'rp.is_noop = 1';
|
||||
case 'manual': return 'mi.needs_review = 1 AND mi.original_language IS NULL';
|
||||
case 'approved': return "rp.status = 'approved'";
|
||||
case 'skipped': return "rp.status = 'skipped'";
|
||||
case 'done': return "rp.status = 'done'";
|
||||
case 'error': return "rp.status = 'error'";
|
||||
default: return '1=1';
|
||||
}
|
||||
}
|
||||
|
||||
type RawRow = MediaItem & {
|
||||
plan_id: number | null; plan_status: string | null; is_noop: number | null;
|
||||
plan_notes: string | null; reviewed_at: string | null; plan_created_at: string | null;
|
||||
remove_count: number; keep_count: number;
|
||||
};
|
||||
|
||||
function rowToPlan(r: RawRow): ReviewPlan | null {
|
||||
if (r.plan_id == null) return null;
|
||||
return { id: r.plan_id, item_id: r.id, status: r.plan_status ?? 'pending', is_noop: r.is_noop ?? 0, notes: r.plan_notes, reviewed_at: r.reviewed_at, created_at: r.plan_created_at ?? '' } as ReviewPlan;
|
||||
}
|
||||
|
||||
function loadItemDetail(db: ReturnType<typeof getDb>, itemId: number) {
|
||||
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(itemId) as MediaItem | undefined;
|
||||
if (!item) return { item: null, streams: [], plan: null, decisions: [], command: null, dockerCommand: null, dockerMountDir: null };
|
||||
|
||||
const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(itemId) as MediaStream[];
|
||||
const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(itemId) as ReviewPlan | undefined | null;
|
||||
const decisions = plan ? db.prepare('SELECT * FROM stream_decisions WHERE plan_id = ?').all(plan.id) as StreamDecision[] : [];
|
||||
|
||||
const command = plan && !plan.is_noop ? buildCommand(item, streams, decisions) : null;
|
||||
const cfg = getAllConfig();
|
||||
let dockerCommand: string | null = null;
|
||||
let dockerMountDir: string | null = null;
|
||||
if (plan && !plan.is_noop) {
|
||||
const result = buildDockerCommand(item, streams, decisions, { moviesPath: cfg.movies_path || undefined, seriesPath: cfg.series_path || undefined });
|
||||
dockerCommand = result.command;
|
||||
dockerMountDir = result.mountDir;
|
||||
}
|
||||
|
||||
return { item, streams, plan: plan ?? null, decisions, command, dockerCommand, dockerMountDir };
|
||||
}
|
||||
|
||||
function reanalyze(db: ReturnType<typeof getDb>, itemId: number): void {
|
||||
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(itemId) as MediaItem;
|
||||
if (!item) return;
|
||||
|
||||
const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(itemId) as MediaStream[];
|
||||
const subtitleLanguages = getSubtitleLanguages();
|
||||
const analysis = analyzeItem({ original_language: item.original_language, needs_review: item.needs_review }, streams, { subtitleLanguages });
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO review_plans (item_id, status, is_noop, notes)
|
||||
VALUES (?, 'pending', ?, ?)
|
||||
ON CONFLICT(item_id) DO UPDATE SET status = 'pending', is_noop = excluded.is_noop, notes = excluded.notes
|
||||
`).run(itemId, analysis.is_noop ? 1 : 0, analysis.notes);
|
||||
|
||||
const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number };
|
||||
const existingTitles = new Map<number, string | null>(
|
||||
(db.prepare('SELECT stream_id, custom_title FROM stream_decisions WHERE plan_id = ?').all(plan.id) as { stream_id: number; custom_title: string | null }[])
|
||||
.map((r) => [r.stream_id, r.custom_title])
|
||||
);
|
||||
db.prepare('DELETE FROM stream_decisions WHERE plan_id = ?').run(plan.id);
|
||||
for (const dec of analysis.decisions) {
|
||||
db.prepare('INSERT INTO stream_decisions (plan_id, stream_id, action, target_index, custom_title) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(plan.id, dec.stream_id, dec.action, dec.target_index, existingTitles.get(dec.stream_id) ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── List ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/', (c) => {
|
||||
const db = getDb();
|
||||
const filter = c.req.query('filter') ?? 'all';
|
||||
const where = buildWhereClause(filter);
|
||||
|
||||
const movieRows = db.prepare(`
|
||||
SELECT mi.*, rp.id as plan_id, rp.status as plan_status, rp.is_noop, rp.notes as plan_notes,
|
||||
rp.reviewed_at, rp.created_at as plan_created_at,
|
||||
COUNT(CASE WHEN sd.action = 'remove' THEN 1 END) as remove_count,
|
||||
COUNT(CASE WHEN sd.action = 'keep' THEN 1 END) as keep_count
|
||||
FROM media_items mi
|
||||
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
||||
LEFT JOIN stream_decisions sd ON sd.plan_id = rp.id
|
||||
WHERE mi.type = 'Movie' AND ${where}
|
||||
GROUP BY mi.id ORDER BY mi.name LIMIT 500
|
||||
`).all() as RawRow[];
|
||||
|
||||
const movies = movieRows.map((r) => ({ item: r as unknown as MediaItem, plan: rowToPlan(r), removeCount: r.remove_count, keepCount: r.keep_count }));
|
||||
|
||||
const series = db.prepare(`
|
||||
SELECT COALESCE(mi.series_jellyfin_id, mi.series_name) as series_key, mi.series_name,
|
||||
MAX(mi.original_language) as original_language,
|
||||
COUNT(DISTINCT mi.season_number) as season_count, COUNT(mi.id) as episode_count,
|
||||
SUM(CASE WHEN rp.is_noop = 1 THEN 1 ELSE 0 END) as noop_count,
|
||||
SUM(CASE WHEN rp.status = 'pending' AND rp.is_noop = 0 THEN 1 ELSE 0 END) as needs_action_count,
|
||||
SUM(CASE WHEN rp.status = 'approved' THEN 1 ELSE 0 END) as approved_count,
|
||||
SUM(CASE WHEN rp.status = 'skipped' THEN 1 ELSE 0 END) as skipped_count,
|
||||
SUM(CASE WHEN rp.status = 'done' THEN 1 ELSE 0 END) as done_count,
|
||||
SUM(CASE WHEN rp.status = 'error' THEN 1 ELSE 0 END) as error_count,
|
||||
SUM(CASE WHEN mi.needs_review = 1 AND mi.original_language IS NULL THEN 1 ELSE 0 END) as manual_count
|
||||
FROM media_items mi
|
||||
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
||||
WHERE mi.type = 'Episode' AND ${where}
|
||||
GROUP BY series_key ORDER BY mi.series_name
|
||||
`).all();
|
||||
|
||||
const totalCounts = countsByFilter(db);
|
||||
return c.json({ movies, series, filter, totalCounts });
|
||||
});
|
||||
|
||||
// ─── Series episodes ──────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/series/:seriesKey/episodes', (c) => {
|
||||
const db = getDb();
|
||||
const seriesKey = decodeURIComponent(c.req.param('seriesKey'));
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT mi.*, rp.id as plan_id, rp.status as plan_status, rp.is_noop, rp.notes as plan_notes,
|
||||
rp.reviewed_at, rp.created_at as plan_created_at,
|
||||
COUNT(CASE WHEN sd.action = 'remove' THEN 1 END) as remove_count, 0 as keep_count
|
||||
FROM media_items mi
|
||||
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
||||
LEFT JOIN stream_decisions sd ON sd.plan_id = rp.id
|
||||
WHERE mi.type = 'Episode'
|
||||
AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?))
|
||||
GROUP BY mi.id ORDER BY mi.season_number, mi.episode_number
|
||||
`).all(seriesKey, seriesKey) as RawRow[];
|
||||
|
||||
const seasonMap = new Map<number | null, unknown[]>();
|
||||
for (const r of rows) {
|
||||
const season = (r as unknown as { season_number: number | null }).season_number ?? null;
|
||||
if (!seasonMap.has(season)) seasonMap.set(season, []);
|
||||
seasonMap.get(season)!.push({ item: r as unknown as MediaItem, plan: rowToPlan(r), removeCount: r.remove_count });
|
||||
}
|
||||
|
||||
const seasons = Array.from(seasonMap.entries())
|
||||
.sort(([a], [b]) => (a ?? -1) - (b ?? -1))
|
||||
.map(([season, episodes]) => ({
|
||||
season,
|
||||
episodes,
|
||||
noopCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.is_noop).length,
|
||||
actionCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.status === 'pending' && !e.plan.is_noop).length,
|
||||
approvedCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.status === 'approved').length,
|
||||
doneCount: (episodes as { plan: ReviewPlan | null }[]).filter((e) => e.plan?.status === 'done').length,
|
||||
}));
|
||||
|
||||
return c.json({ seasons });
|
||||
});
|
||||
|
||||
// ─── Approve series ───────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/series/:seriesKey/approve-all', (c) => {
|
||||
const db = getDb();
|
||||
const seriesKey = decodeURIComponent(c.req.param('seriesKey'));
|
||||
const pending = db.prepare(`
|
||||
SELECT rp.*, mi.id as item_id FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id
|
||||
WHERE mi.type = 'Episode' AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?))
|
||||
AND rp.status = 'pending' AND rp.is_noop = 0
|
||||
`).all(seriesKey, seriesKey) as (ReviewPlan & { item_id: number })[];
|
||||
for (const plan of pending) {
|
||||
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
||||
const { item, streams, decisions } = loadItemDetail(db, plan.item_id);
|
||||
if (item) db.prepare("INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')").run(plan.item_id, buildCommand(item, streams, decisions));
|
||||
}
|
||||
return c.json({ ok: true, count: pending.length });
|
||||
});
|
||||
|
||||
// ─── Approve season ───────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/season/:seriesKey/:season/approve-all', (c) => {
|
||||
const db = getDb();
|
||||
const seriesKey = decodeURIComponent(c.req.param('seriesKey'));
|
||||
const season = Number(c.req.param('season'));
|
||||
const pending = db.prepare(`
|
||||
SELECT rp.*, mi.id as item_id FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id
|
||||
WHERE mi.type = 'Episode' AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?))
|
||||
AND mi.season_number = ? AND rp.status = 'pending' AND rp.is_noop = 0
|
||||
`).all(seriesKey, seriesKey, season) as (ReviewPlan & { item_id: number })[];
|
||||
for (const plan of pending) {
|
||||
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
||||
const { item, streams, decisions } = loadItemDetail(db, plan.item_id);
|
||||
if (item) db.prepare("INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')").run(plan.item_id, buildCommand(item, streams, decisions));
|
||||
}
|
||||
return c.json({ ok: true, count: pending.length });
|
||||
});
|
||||
|
||||
// ─── Approve all ──────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/approve-all', (c) => {
|
||||
const db = getDb();
|
||||
const pending = db.prepare(
|
||||
"SELECT rp.*, mi.id as item_id FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE rp.status = 'pending' AND rp.is_noop = 0"
|
||||
).all() as (ReviewPlan & { item_id: number })[];
|
||||
for (const plan of pending) {
|
||||
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
||||
const { item, streams, decisions } = loadItemDetail(db, plan.item_id);
|
||||
if (item) db.prepare("INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')").run(plan.item_id, buildCommand(item, streams, decisions));
|
||||
}
|
||||
return c.json({ ok: true, count: pending.length });
|
||||
});
|
||||
|
||||
// ─── Detail ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/:id', (c) => {
|
||||
const db = getDb();
|
||||
const id = Number(c.req.param('id'));
|
||||
const detail = loadItemDetail(db, id);
|
||||
if (!detail.item) return c.notFound();
|
||||
return c.json(detail);
|
||||
});
|
||||
|
||||
// ─── Override language ────────────────────────────────────────────────────────
|
||||
|
||||
app.patch('/:id/language', async (c) => {
|
||||
const db = getDb();
|
||||
const id = Number(c.req.param('id'));
|
||||
const body = await c.req.json<{ language: string | null }>();
|
||||
const lang = body.language || null;
|
||||
db.prepare("UPDATE media_items SET original_language = ?, orig_lang_source = 'manual', needs_review = 0 WHERE id = ?")
|
||||
.run(lang ? normalizeLanguage(lang) : null, id);
|
||||
reanalyze(db, id);
|
||||
const detail = loadItemDetail(db, id);
|
||||
if (!detail.item) return c.notFound();
|
||||
return c.json(detail);
|
||||
});
|
||||
|
||||
// ─── Edit stream title ────────────────────────────────────────────────────────
|
||||
|
||||
app.patch('/:id/stream/:streamId/title', async (c) => {
|
||||
const db = getDb();
|
||||
const itemId = Number(c.req.param('id'));
|
||||
const streamId = Number(c.req.param('streamId'));
|
||||
const body = await c.req.json<{ title: string }>();
|
||||
const title = (body.title ?? '').trim() || null;
|
||||
const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number } | undefined;
|
||||
if (!plan) return c.notFound();
|
||||
db.prepare('UPDATE stream_decisions SET custom_title = ? WHERE plan_id = ? AND stream_id = ?').run(title, plan.id, streamId);
|
||||
const detail = loadItemDetail(db, itemId);
|
||||
if (!detail.item) return c.notFound();
|
||||
return c.json(detail);
|
||||
});
|
||||
|
||||
// ─── Toggle stream action ─────────────────────────────────────────────────────
|
||||
|
||||
app.patch('/:id/stream/:streamId', async (c) => {
|
||||
const db = getDb();
|
||||
const itemId = Number(c.req.param('id'));
|
||||
const streamId = Number(c.req.param('streamId'));
|
||||
const body = await c.req.json<{ action: 'keep' | 'remove' }>();
|
||||
const action = body.action;
|
||||
|
||||
// Only audio streams can be toggled — subtitles are always removed (extracted to sidecar)
|
||||
const stream = db.prepare('SELECT type FROM media_streams WHERE id = ?').get(streamId) as { type: string } | undefined;
|
||||
if (stream?.type === 'Subtitle') return c.json({ error: 'Subtitle streams cannot be toggled' }, 400);
|
||||
|
||||
const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number } | undefined;
|
||||
if (!plan) return c.notFound();
|
||||
db.prepare('UPDATE stream_decisions SET action = ? WHERE plan_id = ? AND stream_id = ?').run(action, plan.id, streamId);
|
||||
|
||||
// is_noop only considers audio streams (subtitle removal is implicit)
|
||||
const audioNotKept = (db.prepare(`
|
||||
SELECT COUNT(*) as n FROM stream_decisions sd
|
||||
JOIN media_streams ms ON ms.id = sd.stream_id
|
||||
WHERE sd.plan_id = ? AND ms.type = 'Audio' AND sd.action != 'keep'
|
||||
`).get(plan.id) as { n: number }).n;
|
||||
// Also check audio ordering
|
||||
const isNoop = audioNotKept === 0; // simplified — full recheck would need analyzer
|
||||
db.prepare('UPDATE review_plans SET is_noop = ? WHERE id = ?').run(isNoop ? 1 : 0, plan.id);
|
||||
|
||||
const detail = loadItemDetail(db, itemId);
|
||||
if (!detail.item) return c.notFound();
|
||||
return c.json(detail);
|
||||
});
|
||||
|
||||
// ─── Approve ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/:id/approve', (c) => {
|
||||
const db = getDb();
|
||||
const id = Number(c.req.param('id'));
|
||||
const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(id) as ReviewPlan | undefined;
|
||||
if (!plan) return c.notFound();
|
||||
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
|
||||
if (!plan.is_noop) {
|
||||
const { item, streams, decisions } = loadItemDetail(db, id);
|
||||
if (item) db.prepare("INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')").run(id, buildCommand(item, streams, decisions));
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Skip / Unskip ───────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/:id/skip', (c) => {
|
||||
const db = getDb();
|
||||
const id = Number(c.req.param('id'));
|
||||
db.prepare("UPDATE review_plans SET status = 'skipped', reviewed_at = datetime('now') WHERE item_id = ?").run(id);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/:id/unskip', (c) => {
|
||||
const db = getDb();
|
||||
const id = Number(c.req.param('id'));
|
||||
db.prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE item_id = ? AND status = 'skipped'").run(id);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Rescan ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/:id/rescan', async (c) => {
|
||||
const db = getDb();
|
||||
const id = Number(c.req.param('id'));
|
||||
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(id) as MediaItem | undefined;
|
||||
if (!item) return c.notFound();
|
||||
|
||||
const cfg = getAllConfig();
|
||||
const jfCfg = { url: cfg.jellyfin_url, apiKey: cfg.jellyfin_api_key, userId: cfg.jellyfin_user_id };
|
||||
|
||||
// Trigger Jellyfin's internal metadata probe and wait for it to finish
|
||||
// so the streams we fetch afterwards reflect the current file on disk.
|
||||
await refreshItem(jfCfg, item.jellyfin_id);
|
||||
|
||||
const fresh = await getItem(jfCfg, item.jellyfin_id);
|
||||
if (fresh) {
|
||||
const insertStream = db.prepare(`
|
||||
INSERT INTO media_streams (item_id, stream_index, type, codec, language, language_display,
|
||||
title, is_default, is_forced, is_hearing_impaired, channels, channel_layout, bit_rate, sample_rate)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
db.prepare('DELETE FROM media_streams WHERE item_id = ?').run(id);
|
||||
for (const jStream of fresh.MediaStreams ?? []) {
|
||||
if (jStream.IsExternal) continue; // skip external subs — not embedded in container
|
||||
const s = mapStream(jStream);
|
||||
insertStream.run(id, s.stream_index, s.type, s.codec, s.language, s.language_display, s.title, s.is_default, s.is_forced, s.is_hearing_impaired, s.channels, s.channel_layout, s.bit_rate, s.sample_rate);
|
||||
}
|
||||
}
|
||||
|
||||
reanalyze(db, id);
|
||||
const detail = loadItemDetail(db, id);
|
||||
if (!detail.item) return c.notFound();
|
||||
return c.json(detail);
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -1,18 +1,15 @@
|
||||
import { Hono } from 'hono';
|
||||
import { stream } from 'hono/streaming';
|
||||
import { getDb, getConfig, setConfig, getAllConfig } from '../db/index';
|
||||
import { getAllItems, extractOriginalLanguage, mapStream, normalizeLanguage } from '../services/jellyfin';
|
||||
import { getAllItems, getDevItems, extractOriginalLanguage, mapStream, normalizeLanguage } from '../services/jellyfin';
|
||||
import { getOriginalLanguage as radarrLang } from '../services/radarr';
|
||||
import { getOriginalLanguage as sonarrLang } from '../services/sonarr';
|
||||
import { analyzeItem } from '../services/analyzer';
|
||||
import { buildCommand } from '../services/ffmpeg';
|
||||
import type { MediaItem, MediaStream } from '../types';
|
||||
import { ScanPage } from '../views/scan';
|
||||
import { DashboardPage } from '../views/dashboard';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// ─── State: single in-process scan ───────────────────────────────────────────
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
let scanAbort: AbortController | null = null;
|
||||
const scanListeners = new Set<(data: string) => void>();
|
||||
@@ -22,7 +19,12 @@ function emitSse(type: string, data: unknown): void {
|
||||
for (const listener of scanListeners) listener(line);
|
||||
}
|
||||
|
||||
// ─── Pages ────────────────────────────────────────────────────────────────────
|
||||
function currentScanLimit(): number | null {
|
||||
const v = getConfig('scan_limit');
|
||||
return v ? Number(v) : null;
|
||||
}
|
||||
|
||||
// ─── Status ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/', (c) => {
|
||||
const db = getDb();
|
||||
@@ -31,43 +33,44 @@ app.get('/', (c) => {
|
||||
const scanned = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'scanned'").get() as { n: number }).n;
|
||||
const errors = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'error'").get() as { n: number }).n;
|
||||
const recentItems = db.prepare(
|
||||
"SELECT name, type, scan_status FROM media_items ORDER BY last_scanned_at DESC LIMIT 50"
|
||||
'SELECT name, type, scan_status FROM media_items ORDER BY last_scanned_at DESC LIMIT 50'
|
||||
).all() as { name: string; type: string; scan_status: string }[];
|
||||
|
||||
return c.html(
|
||||
<ScanPage
|
||||
running={running}
|
||||
progress={{ scanned, total, errors }}
|
||||
recentItems={recentItems}
|
||||
/>
|
||||
);
|
||||
return c.json({ running, progress: { scanned, total, errors }, recentItems, scanLimit: currentScanLimit() });
|
||||
});
|
||||
|
||||
// ─── Start scan ───────────────────────────────────────────────────────────────
|
||||
// ─── Start ────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/start', async (c) => {
|
||||
if (getConfig('scan_running') === '1') {
|
||||
return c.redirect('/scan');
|
||||
return c.json({ ok: false, error: 'Scan already running' }, 409);
|
||||
}
|
||||
|
||||
const body = await c.req.json<{ limit?: number }>().catch(() => ({}));
|
||||
const formLimit = body.limit ?? null;
|
||||
const envLimit = process.env.SCAN_LIMIT ? Number(process.env.SCAN_LIMIT) : null;
|
||||
const limit = formLimit ?? envLimit ?? null;
|
||||
setConfig('scan_limit', limit != null ? String(limit) : '');
|
||||
setConfig('scan_running', '1');
|
||||
// Start scan in background (fire and forget)
|
||||
runScan().catch((err) => {
|
||||
|
||||
runScan(limit).catch((err) => {
|
||||
console.error('Scan error:', err);
|
||||
setConfig('scan_running', '0');
|
||||
emitSse('error', { message: String(err) });
|
||||
});
|
||||
return c.redirect('/scan');
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Stop scan ────────────────────────────────────────────────────────────────
|
||||
// ─── Stop ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/stop', (c) => {
|
||||
scanAbort?.abort();
|
||||
setConfig('scan_running', '0');
|
||||
return c.redirect('/scan');
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── SSE stream ───────────────────────────────────────────────────────────────
|
||||
// ─── SSE ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/events', (c) => {
|
||||
return stream(c, async (s) => {
|
||||
@@ -93,12 +96,10 @@ app.get('/events', (c) => {
|
||||
} else {
|
||||
await new Promise<void>((res) => {
|
||||
resolve = res;
|
||||
setTimeout(res, 15_000); // keepalive every 15s
|
||||
setTimeout(res, 25_000);
|
||||
});
|
||||
resolve = null;
|
||||
if (queue.length === 0) {
|
||||
await s.write(': keepalive\n\n');
|
||||
}
|
||||
if (queue.length === 0) await s.write(': keepalive\n\n');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -109,39 +110,42 @@ app.get('/events', (c) => {
|
||||
|
||||
// ─── Core scan logic ──────────────────────────────────────────────────────────
|
||||
|
||||
async function runScan(): Promise<void> {
|
||||
async function runScan(limit: number | null = null): Promise<void> {
|
||||
scanAbort = new AbortController();
|
||||
const { signal } = scanAbort;
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const db = getDb();
|
||||
const cfg = getAllConfig();
|
||||
|
||||
const jellyfinCfg = {
|
||||
url: cfg.jellyfin_url,
|
||||
apiKey: cfg.jellyfin_api_key,
|
||||
userId: cfg.jellyfin_user_id,
|
||||
};
|
||||
if (isDev) {
|
||||
db.prepare('DELETE FROM stream_decisions').run();
|
||||
db.prepare('DELETE FROM review_plans').run();
|
||||
db.prepare('DELETE FROM media_streams').run();
|
||||
db.prepare('DELETE FROM media_items').run();
|
||||
}
|
||||
|
||||
const cfg = getAllConfig();
|
||||
const jellyfinCfg = { url: cfg.jellyfin_url, apiKey: cfg.jellyfin_api_key, userId: cfg.jellyfin_user_id };
|
||||
const subtitleLanguages: string[] = JSON.parse(cfg.subtitle_languages ?? '["eng","deu","spa"]');
|
||||
const radarrEnabled = cfg.radarr_enabled === '1';
|
||||
const sonarrEnabled = cfg.sonarr_enabled === '1';
|
||||
|
||||
let scanned = 0;
|
||||
let processed = 0;
|
||||
let errors = 0;
|
||||
let total = 0;
|
||||
let total = isDev ? 250 : 0;
|
||||
|
||||
// Count total items first (rough)
|
||||
try {
|
||||
const countUrl = new URL(`${jellyfinCfg.url}/Users/${jellyfinCfg.userId}/Items`);
|
||||
countUrl.searchParams.set('Recursive', 'true');
|
||||
countUrl.searchParams.set('IncludeItemTypes', 'Movie,Episode');
|
||||
countUrl.searchParams.set('Limit', '1');
|
||||
const countRes = await fetch(countUrl.toString(), {
|
||||
headers: { 'X-Emby-Token': jellyfinCfg.apiKey },
|
||||
});
|
||||
if (countRes.ok) {
|
||||
const body = await countRes.json() as { TotalRecordCount: number };
|
||||
total = body.TotalRecordCount;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
if (!isDev) {
|
||||
try {
|
||||
const countUrl = new URL(`${jellyfinCfg.url}/Users/${jellyfinCfg.userId}/Items`);
|
||||
countUrl.searchParams.set('Recursive', 'true');
|
||||
countUrl.searchParams.set('IncludeItemTypes', 'Movie,Episode');
|
||||
countUrl.searchParams.set('Limit', '1');
|
||||
const countRes = await fetch(countUrl.toString(), { headers: { 'X-Emby-Token': jellyfinCfg.apiKey } });
|
||||
if (countRes.ok) {
|
||||
const body = (await countRes.json()) as { TotalRecordCount: number };
|
||||
total = limit != null ? Math.min(limit, body.TotalRecordCount) : body.TotalRecordCount;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const upsertItem = db.prepare(`
|
||||
INSERT INTO media_items (
|
||||
@@ -150,29 +154,16 @@ async function runScan(): Promise<void> {
|
||||
original_language, orig_lang_source, needs_review,
|
||||
imdb_id, tmdb_id, tvdb_id,
|
||||
scan_status, last_scanned_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
'scanned', datetime('now')
|
||||
)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'scanned', datetime('now'))
|
||||
ON CONFLICT(jellyfin_id) DO UPDATE SET
|
||||
type = excluded.type,
|
||||
name = excluded.name,
|
||||
series_name = excluded.series_name,
|
||||
series_jellyfin_id = excluded.series_jellyfin_id,
|
||||
season_number = excluded.season_number,
|
||||
episode_number = excluded.episode_number,
|
||||
year = excluded.year,
|
||||
file_path = excluded.file_path,
|
||||
file_size = excluded.file_size,
|
||||
container = excluded.container,
|
||||
original_language = excluded.original_language,
|
||||
orig_lang_source = excluded.orig_lang_source,
|
||||
needs_review = excluded.needs_review,
|
||||
imdb_id = excluded.imdb_id,
|
||||
tmdb_id = excluded.tmdb_id,
|
||||
tvdb_id = excluded.tvdb_id,
|
||||
scan_status = 'scanned',
|
||||
last_scanned_at = datetime('now')
|
||||
type = excluded.type, name = excluded.name, series_name = excluded.series_name,
|
||||
series_jellyfin_id = excluded.series_jellyfin_id, season_number = excluded.season_number,
|
||||
episode_number = excluded.episode_number, year = excluded.year, file_path = excluded.file_path,
|
||||
file_size = excluded.file_size, container = excluded.container,
|
||||
original_language = excluded.original_language, orig_lang_source = excluded.orig_lang_source,
|
||||
needs_review = excluded.needs_review, imdb_id = excluded.imdb_id,
|
||||
tmdb_id = excluded.tmdb_id, tvdb_id = excluded.tvdb_id,
|
||||
scan_status = 'scanned', last_scanned_at = datetime('now')
|
||||
`);
|
||||
|
||||
const deleteStreams = db.prepare('DELETE FROM media_streams WHERE item_id = ?');
|
||||
@@ -183,38 +174,31 @@ async function runScan(): Promise<void> {
|
||||
channels, channel_layout, bit_rate, sample_rate
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const upsertPlan = db.prepare(`
|
||||
INSERT INTO review_plans (item_id, status, is_noop, notes)
|
||||
VALUES (?, 'pending', ?, ?)
|
||||
ON CONFLICT(item_id) DO UPDATE SET
|
||||
is_noop = excluded.is_noop,
|
||||
notes = excluded.notes
|
||||
ON CONFLICT(item_id) DO UPDATE SET is_noop = excluded.is_noop, notes = excluded.notes
|
||||
`);
|
||||
|
||||
const upsertDecision = db.prepare(`
|
||||
INSERT INTO stream_decisions (plan_id, stream_id, action, target_index)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(plan_id, stream_id) DO UPDATE SET
|
||||
action = excluded.action,
|
||||
target_index = excluded.target_index
|
||||
ON CONFLICT(plan_id, stream_id) DO UPDATE SET action = excluded.action, target_index = excluded.target_index
|
||||
`);
|
||||
|
||||
const getItemByJellyfinId = db.prepare('SELECT id FROM media_items WHERE jellyfin_id = ?');
|
||||
const getPlanByItemId = db.prepare('SELECT id FROM review_plans WHERE item_id = ?');
|
||||
const getStreamsByItemId = db.prepare('SELECT * FROM media_streams WHERE item_id = ?');
|
||||
|
||||
for await (const jellyfinItem of getAllItems(jellyfinCfg)) {
|
||||
const itemSource = isDev ? getDevItems(jellyfinCfg) : getAllItems(jellyfinCfg);
|
||||
for await (const jellyfinItem of itemSource) {
|
||||
if (signal.aborted) break;
|
||||
if (!isDev && limit != null && processed >= limit) break;
|
||||
if (!jellyfinItem.Name || !jellyfinItem.Path) {
|
||||
console.warn(`Skipping item without name/path: id=${jellyfinItem.Id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
scanned++;
|
||||
emitSse('progress', {
|
||||
scanned,
|
||||
total,
|
||||
current_item: jellyfinItem.Name,
|
||||
errors,
|
||||
running: true,
|
||||
});
|
||||
processed++;
|
||||
emitSse('progress', { scanned: processed, total, current_item: jellyfinItem.Name, errors, running: true });
|
||||
|
||||
try {
|
||||
const providerIds = jellyfinItem.ProviderIds ?? {};
|
||||
@@ -222,120 +206,56 @@ async function runScan(): Promise<void> {
|
||||
const tmdbId = providerIds['Tmdb'] ?? null;
|
||||
const tvdbId = providerIds['Tvdb'] ?? null;
|
||||
|
||||
// Determine original language
|
||||
let origLang: string | null = extractOriginalLanguage(jellyfinItem);
|
||||
let origLangSource: string = 'jellyfin';
|
||||
let origLangSource = 'jellyfin';
|
||||
let needsReview = origLang ? 0 : 1;
|
||||
|
||||
// Cross-check with Radarr (movies)
|
||||
if (jellyfinItem.Type === 'Movie' && radarrEnabled && (tmdbId || imdbId)) {
|
||||
const radarrLanguage = await radarrLang(
|
||||
{ url: cfg.radarr_url, apiKey: cfg.radarr_api_key },
|
||||
{ tmdbId: tmdbId ?? undefined, imdbId: imdbId ?? undefined }
|
||||
);
|
||||
if (radarrLanguage) {
|
||||
if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(radarrLanguage)) {
|
||||
// Conflict: prefer Radarr, flag for review
|
||||
needsReview = 1;
|
||||
}
|
||||
origLang = radarrLanguage;
|
||||
origLangSource = 'radarr';
|
||||
}
|
||||
const lang = await radarrLang({ url: cfg.radarr_url, apiKey: cfg.radarr_api_key }, { tmdbId: tmdbId ?? undefined, imdbId: imdbId ?? undefined });
|
||||
if (lang) { if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; origLang = lang; origLangSource = 'radarr'; }
|
||||
}
|
||||
|
||||
// Cross-check with Sonarr (episodes)
|
||||
if (jellyfinItem.Type === 'Episode' && sonarrEnabled && tvdbId) {
|
||||
const sonarrLanguage = await sonarrLang(
|
||||
{ url: cfg.sonarr_url, apiKey: cfg.sonarr_api_key },
|
||||
tvdbId
|
||||
);
|
||||
if (sonarrLanguage) {
|
||||
if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(sonarrLanguage)) {
|
||||
needsReview = 1;
|
||||
}
|
||||
origLang = sonarrLanguage;
|
||||
origLangSource = 'sonarr';
|
||||
}
|
||||
const lang = await sonarrLang({ url: cfg.sonarr_url, apiKey: cfg.sonarr_api_key }, tvdbId);
|
||||
if (lang) { if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; origLang = lang; origLangSource = 'sonarr'; }
|
||||
}
|
||||
|
||||
// Upsert item
|
||||
upsertItem.run(
|
||||
jellyfinItem.Id,
|
||||
jellyfinItem.Type === 'Episode' ? 'Episode' : 'Movie',
|
||||
jellyfinItem.Name,
|
||||
jellyfinItem.SeriesName ?? null,
|
||||
jellyfinItem.SeriesId ?? null,
|
||||
jellyfinItem.ParentIndexNumber ?? null,
|
||||
jellyfinItem.IndexNumber ?? null,
|
||||
jellyfinItem.ProductionYear ?? null,
|
||||
jellyfinItem.Path ?? '',
|
||||
jellyfinItem.Size ?? null,
|
||||
jellyfinItem.Container ?? null,
|
||||
origLang,
|
||||
origLangSource,
|
||||
needsReview,
|
||||
imdbId,
|
||||
tmdbId,
|
||||
tvdbId
|
||||
jellyfinItem.Id, jellyfinItem.Type === 'Episode' ? 'Episode' : 'Movie',
|
||||
jellyfinItem.Name, jellyfinItem.SeriesName ?? null, jellyfinItem.SeriesId ?? null,
|
||||
jellyfinItem.ParentIndexNumber ?? null, jellyfinItem.IndexNumber ?? null,
|
||||
jellyfinItem.ProductionYear ?? null, jellyfinItem.Path, jellyfinItem.Size ?? null,
|
||||
jellyfinItem.Container ?? null, origLang, origLangSource, needsReview,
|
||||
imdbId, tmdbId, tvdbId
|
||||
);
|
||||
|
||||
const itemRow = getItemByJellyfinId.get(jellyfinItem.Id) as { id: number };
|
||||
const itemId = itemRow.id;
|
||||
|
||||
// Upsert streams
|
||||
deleteStreams.run(itemId);
|
||||
for (const jStream of jellyfinItem.MediaStreams ?? []) {
|
||||
if (jStream.IsExternal) continue; // skip external subs — not embedded in container
|
||||
const s = mapStream(jStream);
|
||||
insertStream.run(
|
||||
itemId,
|
||||
s.stream_index,
|
||||
s.type,
|
||||
s.codec,
|
||||
s.language,
|
||||
s.language_display,
|
||||
s.title,
|
||||
s.is_default,
|
||||
s.is_forced,
|
||||
s.is_hearing_impaired,
|
||||
s.channels,
|
||||
s.channel_layout,
|
||||
s.bit_rate,
|
||||
s.sample_rate
|
||||
);
|
||||
insertStream.run(itemId, s.stream_index, s.type, s.codec, s.language, s.language_display, s.title, s.is_default, s.is_forced, s.is_hearing_impaired, s.channels, s.channel_layout, s.bit_rate, s.sample_rate);
|
||||
}
|
||||
|
||||
// Run analyzer
|
||||
const streams = getStreamsByItemId.all(itemId) as MediaStream[];
|
||||
const analysis = analyzeItem(
|
||||
{ original_language: origLang, needs_review: needsReview },
|
||||
streams,
|
||||
{ subtitleLanguages }
|
||||
);
|
||||
|
||||
const analysis = analyzeItem({ original_language: origLang, needs_review: needsReview }, streams, { subtitleLanguages });
|
||||
upsertPlan.run(itemId, analysis.is_noop ? 1 : 0, analysis.notes);
|
||||
|
||||
const planRow = getPlanByItemId.get(itemId) as { id: number };
|
||||
const planId = planRow.id;
|
||||
|
||||
for (const dec of analysis.decisions) {
|
||||
upsertDecision.run(planId, dec.stream_id, dec.action, dec.target_index);
|
||||
}
|
||||
for (const dec of analysis.decisions) upsertDecision.run(planRow.id, dec.stream_id, dec.action, dec.target_index);
|
||||
|
||||
emitSse('log', { name: jellyfinItem.Name, type: jellyfinItem.Type, status: 'scanned' });
|
||||
} catch (err) {
|
||||
errors++;
|
||||
console.error(`Error scanning ${jellyfinItem.Name}:`, err);
|
||||
try {
|
||||
db.prepare(
|
||||
"UPDATE media_items SET scan_status = 'error', scan_error = ? WHERE jellyfin_id = ?"
|
||||
).run(String(err), jellyfinItem.Id);
|
||||
} catch { /* ignore */ }
|
||||
try { db.prepare("UPDATE media_items SET scan_status = 'error', scan_error = ? WHERE jellyfin_id = ?").run(String(err), jellyfinItem.Id); } catch { /* ignore */ }
|
||||
emitSse('log', { name: jellyfinItem.Name, type: jellyfinItem.Type, status: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
setConfig('scan_running', '0');
|
||||
emitSse('complete', { scanned, total, errors });
|
||||
emitSse('complete', { scanned: processed, total, errors });
|
||||
}
|
||||
|
||||
export default app;
|
||||
102
server/api/setup.ts
Normal file
102
server/api/setup.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Hono } from 'hono';
|
||||
import { setConfig, getAllConfig, getDb, getEnvLockedKeys } from '../db/index';
|
||||
import { testConnection as testJellyfin, getUsers } from '../services/jellyfin';
|
||||
import { testConnection as testRadarr } from '../services/radarr';
|
||||
import { testConnection as testSonarr } from '../services/sonarr';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get('/', (c) => {
|
||||
const config = getAllConfig();
|
||||
const envLocked = Array.from(getEnvLockedKeys());
|
||||
return c.json({ config, envLocked });
|
||||
});
|
||||
|
||||
app.post('/jellyfin', async (c) => {
|
||||
const body = await c.req.json<{ url: string; api_key: string }>();
|
||||
const url = body.url?.replace(/\/$/, '');
|
||||
const apiKey = body.api_key;
|
||||
|
||||
if (!url || !apiKey) return c.json({ ok: false, error: 'URL and API key are required' }, 400);
|
||||
|
||||
const result = await testJellyfin({ url, apiKey });
|
||||
if (!result.ok) return c.json({ ok: false, error: result.error });
|
||||
|
||||
setConfig('jellyfin_url', url);
|
||||
setConfig('jellyfin_api_key', apiKey);
|
||||
setConfig('setup_complete', '1');
|
||||
|
||||
try {
|
||||
const users = await getUsers({ url, apiKey });
|
||||
const admin = users.find((u) => u.Name === 'admin') ?? users[0];
|
||||
if (admin?.Id) setConfig('jellyfin_user_id', admin.Id);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/radarr', async (c) => {
|
||||
const body = await c.req.json<{ url?: string; api_key?: string }>();
|
||||
const url = body.url?.replace(/\/$/, '');
|
||||
const apiKey = body.api_key;
|
||||
|
||||
if (!url || !apiKey) {
|
||||
setConfig('radarr_enabled', '0');
|
||||
return c.json({ ok: false, error: 'URL and API key are required' }, 400);
|
||||
}
|
||||
|
||||
const result = await testRadarr({ url, apiKey });
|
||||
if (!result.ok) return c.json({ ok: false, error: result.error });
|
||||
|
||||
setConfig('radarr_url', url);
|
||||
setConfig('radarr_api_key', apiKey);
|
||||
setConfig('radarr_enabled', '1');
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/sonarr', async (c) => {
|
||||
const body = await c.req.json<{ url?: string; api_key?: string }>();
|
||||
const url = body.url?.replace(/\/$/, '');
|
||||
const apiKey = body.api_key;
|
||||
|
||||
if (!url || !apiKey) {
|
||||
setConfig('sonarr_enabled', '0');
|
||||
return c.json({ ok: false, error: 'URL and API key are required' }, 400);
|
||||
}
|
||||
|
||||
const result = await testSonarr({ url, apiKey });
|
||||
if (!result.ok) return c.json({ ok: false, error: result.error });
|
||||
|
||||
setConfig('sonarr_url', url);
|
||||
setConfig('sonarr_api_key', apiKey);
|
||||
setConfig('sonarr_enabled', '1');
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/subtitle-languages', async (c) => {
|
||||
const body = await c.req.json<{ langs: string[] }>();
|
||||
if (body.langs?.length > 0) {
|
||||
setConfig('subtitle_languages', JSON.stringify(body.langs));
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/paths', async (c) => {
|
||||
const body = await c.req.json<{ movies_path?: string; series_path?: string }>();
|
||||
const moviesPath = (body.movies_path ?? '').trim().replace(/\/$/, '');
|
||||
const seriesPath = (body.series_path ?? '').trim().replace(/\/$/, '');
|
||||
setConfig('movies_path', moviesPath);
|
||||
setConfig('series_path', seriesPath);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/clear-scan', (c) => {
|
||||
const db = getDb();
|
||||
db.prepare('DELETE FROM media_items').run();
|
||||
db.prepare("UPDATE config SET value = '0' WHERE key = 'scan_running'").run();
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export default app;
|
||||
202
server/api/subtitles.ts
Normal file
202
server/api/subtitles.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { Hono } from 'hono';
|
||||
import { getDb, getAllConfig } from '../db/index';
|
||||
import { buildExtractOnlyCommand, buildDockerExtractOnlyCommand, predictExtractedFiles } from '../services/ffmpeg';
|
||||
import { normalizeLanguage, getItem, refreshItem, mapStream } from '../services/jellyfin';
|
||||
import type { MediaItem, MediaStream, SubtitleFile, ReviewPlan, StreamDecision } from '../types';
|
||||
import { unlinkSync } from 'node:fs';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function loadDetail(db: ReturnType<typeof getDb>, itemId: number) {
|
||||
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(itemId) as MediaItem | undefined;
|
||||
if (!item) return null;
|
||||
|
||||
const subtitleStreams = db.prepare("SELECT * FROM media_streams WHERE item_id = ? AND type = 'Subtitle' ORDER BY stream_index").all(itemId) as MediaStream[];
|
||||
const files = db.prepare('SELECT * FROM subtitle_files WHERE item_id = ? ORDER BY file_path').all(itemId) as SubtitleFile[];
|
||||
const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(itemId) as ReviewPlan | undefined;
|
||||
const decisions = plan
|
||||
? db.prepare("SELECT sd.* FROM stream_decisions sd JOIN media_streams ms ON ms.id = sd.stream_id WHERE sd.plan_id = ? AND ms.type = 'Subtitle'").all(plan.id) as StreamDecision[]
|
||||
: [];
|
||||
const allStreams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(itemId) as MediaStream[];
|
||||
const extractCommand = buildExtractOnlyCommand(item, allStreams);
|
||||
const cfg = getAllConfig();
|
||||
const dockerResult = buildDockerExtractOnlyCommand(item, allStreams, { moviesPath: cfg.movies_path || undefined, seriesPath: cfg.series_path || undefined });
|
||||
|
||||
return {
|
||||
item,
|
||||
subtitleStreams,
|
||||
files,
|
||||
plan: plan ?? null,
|
||||
decisions,
|
||||
subs_extracted: plan?.subs_extracted ?? 0,
|
||||
extractCommand,
|
||||
dockerCommand: dockerResult?.command ?? null,
|
||||
dockerMountDir: dockerResult?.mountDir ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── List ────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/', (c) => {
|
||||
const db = getDb();
|
||||
const filter = c.req.query('filter') ?? 'all';
|
||||
|
||||
let where = '1=1';
|
||||
switch (filter) {
|
||||
case 'not_extracted': where = 'rp.subs_extracted = 0 AND sub_count > 0'; break;
|
||||
case 'extracted': where = 'rp.subs_extracted = 1'; break;
|
||||
case 'no_subs': where = 'sub_count = 0'; break;
|
||||
}
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT mi.id, mi.jellyfin_id, mi.type, mi.name, mi.series_name, mi.season_number,
|
||||
mi.episode_number, mi.year, mi.original_language, mi.file_path,
|
||||
rp.subs_extracted,
|
||||
(SELECT COUNT(*) FROM media_streams ms WHERE ms.item_id = mi.id AND ms.type = 'Subtitle') as sub_count,
|
||||
(SELECT COUNT(*) FROM subtitle_files sf WHERE sf.item_id = mi.id) as file_count
|
||||
FROM media_items mi
|
||||
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
||||
WHERE ${where}
|
||||
ORDER BY mi.name
|
||||
LIMIT 500
|
||||
`).all() as (Pick<MediaItem, 'id' | 'jellyfin_id' | 'type' | 'name' | 'series_name' | 'season_number' | 'episode_number' | 'year' | 'original_language' | 'file_path'> & {
|
||||
subs_extracted: number | null;
|
||||
sub_count: number;
|
||||
file_count: number;
|
||||
})[];
|
||||
|
||||
const totalAll = (db.prepare('SELECT COUNT(*) as n FROM media_items').get() as { n: number }).n;
|
||||
const totalExtracted = (db.prepare('SELECT COUNT(*) as n FROM review_plans WHERE subs_extracted = 1').get() as { n: number }).n;
|
||||
const totalNoSubs = (db.prepare(`
|
||||
SELECT COUNT(*) as n FROM media_items mi
|
||||
WHERE NOT EXISTS (SELECT 1 FROM media_streams ms WHERE ms.item_id = mi.id AND ms.type = 'Subtitle')
|
||||
`).get() as { n: number }).n;
|
||||
const totalNotExtracted = totalAll - totalExtracted - totalNoSubs;
|
||||
|
||||
return c.json({
|
||||
items: rows,
|
||||
filter,
|
||||
totalCounts: { all: totalAll, not_extracted: totalNotExtracted, extracted: totalExtracted, no_subs: totalNoSubs },
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Detail ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/:id', (c) => {
|
||||
const db = getDb();
|
||||
const detail = loadDetail(db, Number(c.req.param('id')));
|
||||
if (!detail) return c.notFound();
|
||||
return c.json(detail);
|
||||
});
|
||||
|
||||
// ─── Edit stream language ────────────────────────────────────────────────────
|
||||
|
||||
app.patch('/:id/stream/:streamId/language', async (c) => {
|
||||
const db = getDb();
|
||||
const itemId = Number(c.req.param('id'));
|
||||
const streamId = Number(c.req.param('streamId'));
|
||||
const body = await c.req.json<{ language: string }>();
|
||||
const lang = (body.language ?? '').trim() || null;
|
||||
|
||||
const stream = db.prepare('SELECT * FROM media_streams WHERE id = ? AND item_id = ?').get(streamId, itemId) as MediaStream | undefined;
|
||||
if (!stream) return c.notFound();
|
||||
|
||||
const normalized = lang ? normalizeLanguage(lang) : null;
|
||||
db.prepare('UPDATE media_streams SET language = ? WHERE id = ?').run(normalized, streamId);
|
||||
|
||||
const detail = loadDetail(db, itemId);
|
||||
if (!detail) return c.notFound();
|
||||
return c.json(detail);
|
||||
});
|
||||
|
||||
// ─── Edit stream title ──────────────────────────────────────────────────────
|
||||
|
||||
app.patch('/:id/stream/:streamId/title', async (c) => {
|
||||
const db = getDb();
|
||||
const itemId = Number(c.req.param('id'));
|
||||
const streamId = Number(c.req.param('streamId'));
|
||||
const body = await c.req.json<{ title: string }>();
|
||||
const title = (body.title ?? '').trim() || null;
|
||||
|
||||
const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number } | undefined;
|
||||
if (!plan) return c.notFound();
|
||||
db.prepare('UPDATE stream_decisions SET custom_title = ? WHERE plan_id = ? AND stream_id = ?').run(title, plan.id, streamId);
|
||||
|
||||
const detail = loadDetail(db, itemId);
|
||||
if (!detail) return c.notFound();
|
||||
return c.json(detail);
|
||||
});
|
||||
|
||||
// ─── Extract ─────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/:id/extract', (c) => {
|
||||
const db = getDb();
|
||||
const id = Number(c.req.param('id'));
|
||||
|
||||
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(id) as MediaItem | undefined;
|
||||
if (!item) return c.notFound();
|
||||
|
||||
const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(id) as ReviewPlan | undefined;
|
||||
if (plan?.subs_extracted) return c.json({ ok: false, error: 'Subtitles already extracted' }, 409);
|
||||
|
||||
const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(id) as MediaStream[];
|
||||
const command = buildExtractOnlyCommand(item, streams);
|
||||
if (!command) return c.json({ ok: false, error: 'No subtitles to extract' }, 400);
|
||||
|
||||
db.prepare("INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')").run(id, command);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Delete file ─────────────────────────────────────────────────────────────
|
||||
|
||||
app.delete('/:id/files/:fileId', (c) => {
|
||||
const db = getDb();
|
||||
const itemId = Number(c.req.param('id'));
|
||||
const fileId = Number(c.req.param('fileId'));
|
||||
|
||||
const file = db.prepare('SELECT * FROM subtitle_files WHERE id = ? AND item_id = ?').get(fileId, itemId) as SubtitleFile | undefined;
|
||||
if (!file) return c.notFound();
|
||||
|
||||
try { unlinkSync(file.file_path); } catch { /* file may not exist */ }
|
||||
db.prepare('DELETE FROM subtitle_files WHERE id = ?').run(fileId);
|
||||
|
||||
const files = db.prepare('SELECT * FROM subtitle_files WHERE item_id = ? ORDER BY file_path').all(itemId) as SubtitleFile[];
|
||||
return c.json({ ok: true, files });
|
||||
});
|
||||
|
||||
// ─── Rescan ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/:id/rescan', async (c) => {
|
||||
const db = getDb();
|
||||
const id = Number(c.req.param('id'));
|
||||
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(id) as MediaItem | undefined;
|
||||
if (!item) return c.notFound();
|
||||
|
||||
const cfg = getAllConfig();
|
||||
const jfCfg = { url: cfg.jellyfin_url, apiKey: cfg.jellyfin_api_key, userId: cfg.jellyfin_user_id };
|
||||
|
||||
await refreshItem(jfCfg, item.jellyfin_id);
|
||||
|
||||
const fresh = await getItem(jfCfg, item.jellyfin_id);
|
||||
if (fresh) {
|
||||
const insertStream = db.prepare(`
|
||||
INSERT INTO media_streams (item_id, stream_index, type, codec, language, language_display,
|
||||
title, is_default, is_forced, is_hearing_impaired, channels, channel_layout, bit_rate, sample_rate)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
db.prepare('DELETE FROM media_streams WHERE item_id = ?').run(id);
|
||||
for (const jStream of fresh.MediaStreams ?? []) {
|
||||
if (jStream.IsExternal) continue;
|
||||
const s = mapStream(jStream);
|
||||
insertStream.run(id, s.stream_index, s.type, s.codec, s.language, s.language_display, s.title, s.is_default, s.is_forced, s.is_hearing_impaired, s.channels, s.channel_layout, s.bit_rate, s.sample_rate);
|
||||
}
|
||||
}
|
||||
|
||||
const detail = loadDetail(db, id);
|
||||
if (!detail) return c.notFound();
|
||||
return c.json(detail);
|
||||
});
|
||||
|
||||
export default app;
|
||||
110
server/db/index.ts
Normal file
110
server/db/index.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { join } from 'node:path';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { SCHEMA, DEFAULT_CONFIG } from './schema';
|
||||
|
||||
const dataDir = process.env.DATA_DIR ?? './data';
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const dbPath = join(dataDir, isDev ? 'netfelix-dev.db' : 'netfelix.db');
|
||||
|
||||
// ─── Env-var → config key mapping ─────────────────────────────────────────────
|
||||
|
||||
const ENV_MAP: Record<string, string> = {
|
||||
jellyfin_url: 'JELLYFIN_URL',
|
||||
jellyfin_api_key: 'JELLYFIN_API_KEY',
|
||||
jellyfin_user_id: 'JELLYFIN_USER_ID',
|
||||
radarr_url: 'RADARR_URL',
|
||||
radarr_api_key: 'RADARR_API_KEY',
|
||||
radarr_enabled: 'RADARR_ENABLED',
|
||||
sonarr_url: 'SONARR_URL',
|
||||
sonarr_api_key: 'SONARR_API_KEY',
|
||||
sonarr_enabled: 'SONARR_ENABLED',
|
||||
subtitle_languages: 'SUBTITLE_LANGUAGES',
|
||||
movies_path: 'MOVIES_PATH',
|
||||
series_path: 'SERIES_PATH',
|
||||
};
|
||||
|
||||
/** Read a config key from environment variables (returns null if not set). */
|
||||
function envValue(key: string): string | null {
|
||||
const envKey = ENV_MAP[key];
|
||||
if (!envKey) return null;
|
||||
const val = process.env[envKey];
|
||||
if (!val) return null;
|
||||
if (key.endsWith('_enabled')) return val === '1' || val.toLowerCase() === 'true' ? '1' : '0';
|
||||
if (key === 'subtitle_languages') return JSON.stringify(val.split(',').map((s) => s.trim()));
|
||||
if (key.endsWith('_url')) return val.replace(/\/$/, '');
|
||||
return val;
|
||||
}
|
||||
|
||||
/** True when minimum required Jellyfin env vars are present — skips the setup wizard. */
|
||||
function isEnvConfigured(): boolean {
|
||||
return !!(process.env.JELLYFIN_URL && process.env.JELLYFIN_API_KEY);
|
||||
}
|
||||
|
||||
// ─── Database ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let _db: Database | null = null;
|
||||
|
||||
export function getDb(): Database {
|
||||
if (_db) return _db;
|
||||
_db = new Database(dbPath, { create: true });
|
||||
_db.exec(SCHEMA);
|
||||
// Migrations for columns added after initial release
|
||||
try { _db.exec('ALTER TABLE stream_decisions ADD COLUMN custom_title TEXT'); } catch { /* already exists */ }
|
||||
try { _db.exec('ALTER TABLE review_plans ADD COLUMN subs_extracted INTEGER NOT NULL DEFAULT 0'); } catch { /* already exists */ }
|
||||
seedDefaults(_db);
|
||||
return _db;
|
||||
}
|
||||
|
||||
function seedDefaults(db: Database): void {
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)'
|
||||
);
|
||||
for (const [key, value] of Object.entries(DEFAULT_CONFIG)) {
|
||||
insert.run(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfig(key: string): string | null {
|
||||
// Env vars take precedence over DB
|
||||
const fromEnv = envValue(key);
|
||||
if (fromEnv !== null) return fromEnv;
|
||||
// Auto-complete setup when all required Jellyfin env vars are present
|
||||
if (key === 'setup_complete' && isEnvConfigured()) return '1';
|
||||
const row = getDb()
|
||||
.prepare('SELECT value FROM config WHERE key = ?')
|
||||
.get(key) as { value: string } | undefined;
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export function setConfig(key: string, value: string): void {
|
||||
getDb()
|
||||
.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)')
|
||||
.run(key, value);
|
||||
}
|
||||
|
||||
/** Returns the set of config keys currently overridden by environment variables. */
|
||||
export function getEnvLockedKeys(): Set<string> {
|
||||
const locked = new Set<string>();
|
||||
for (const key of Object.keys(ENV_MAP)) {
|
||||
if (envValue(key) !== null) locked.add(key);
|
||||
}
|
||||
return locked;
|
||||
}
|
||||
|
||||
export function getAllConfig(): Record<string, string> {
|
||||
const rows = getDb()
|
||||
.prepare('SELECT key, value FROM config')
|
||||
.all() as { key: string; value: string }[];
|
||||
const result = Object.fromEntries(rows.map((r) => [r.key, r.value ?? '']));
|
||||
// Apply env overrides on top of DB values
|
||||
for (const key of Object.keys(ENV_MAP)) {
|
||||
const fromEnv = envValue(key);
|
||||
if (fromEnv !== null) result[key] = fromEnv;
|
||||
}
|
||||
// Auto-complete setup when all required Jellyfin env vars are present
|
||||
if (isEnvConfigured()) result.setup_complete = '1';
|
||||
return result;
|
||||
}
|
||||
@@ -81,9 +81,22 @@ CREATE TABLE IF NOT EXISTS stream_decisions (
|
||||
stream_id INTEGER NOT NULL REFERENCES media_streams(id) ON DELETE CASCADE,
|
||||
action TEXT NOT NULL,
|
||||
target_index INTEGER,
|
||||
custom_title TEXT,
|
||||
UNIQUE(plan_id, stream_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subtitle_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
language TEXT,
|
||||
codec TEXT,
|
||||
is_forced INTEGER NOT NULL DEFAULT 0,
|
||||
is_hearing_impaired INTEGER NOT NULL DEFAULT 0,
|
||||
file_size INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
||||
@@ -111,4 +124,6 @@ export const DEFAULT_CONFIG: Record<string, string> = {
|
||||
sonarr_enabled: '0',
|
||||
subtitle_languages: JSON.stringify(['eng', 'deu', 'spa']),
|
||||
scan_running: '0',
|
||||
movies_path: '',
|
||||
series_path: '',
|
||||
};
|
||||
62
server/index.tsx
Normal file
62
server/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Hono } from 'hono';
|
||||
import { serveStatic } from 'hono/bun';
|
||||
import { cors } from 'hono/cors';
|
||||
import { getDb, getConfig } from './db/index';
|
||||
|
||||
import setupRoutes from './api/setup';
|
||||
import scanRoutes from './api/scan';
|
||||
import reviewRoutes from './api/review';
|
||||
import executeRoutes from './api/execute';
|
||||
import nodesRoutes from './api/nodes';
|
||||
import subtitlesRoutes from './api/subtitles';
|
||||
import dashboardRoutes from './api/dashboard';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// ─── CORS (dev: Vite on :5173 talks to Hono on :3000) ────────────────────────
|
||||
|
||||
app.use('/api/*', cors({ origin: ['http://localhost:5173', 'http://localhost:3000'] }));
|
||||
|
||||
// ─── API routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
app.route('/api/dashboard', dashboardRoutes);
|
||||
app.route('/api/setup', setupRoutes);
|
||||
app.route('/api/scan', scanRoutes);
|
||||
app.route('/api/review', reviewRoutes);
|
||||
app.route('/api/execute', executeRoutes);
|
||||
app.route('/api/subtitles', subtitlesRoutes);
|
||||
app.route('/api/nodes', nodesRoutes);
|
||||
|
||||
// ─── Static assets (production: serve Vite build) ────────────────────────────
|
||||
|
||||
app.use('/assets/*', serveStatic({ root: './dist' }));
|
||||
app.use('/favicon.ico', serveStatic({ path: './dist/favicon.ico' }));
|
||||
|
||||
// ─── SPA fallback ─────────────────────────────────────────────────────────────
|
||||
// All non-API routes serve the React index.html so TanStack Router handles them.
|
||||
|
||||
app.get('*', (c) => {
|
||||
const accept = c.req.header('Accept') ?? '';
|
||||
if (c.req.path.startsWith('/api/')) return c.notFound();
|
||||
// In dev the Vite server handles the SPA. In production serve dist/index.html.
|
||||
try {
|
||||
const html = Bun.file('./dist/index.html').text();
|
||||
return html.then((text) => c.html(text));
|
||||
} catch {
|
||||
return c.text('Run `bun build` first to generate the frontend.', 503);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Start ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const port = Number(process.env.PORT ?? '3000');
|
||||
|
||||
console.log(`netfelix-audio-fix API on http://localhost:${port}`);
|
||||
|
||||
getDb();
|
||||
|
||||
export default {
|
||||
port,
|
||||
fetch: app.fetch,
|
||||
idleTimeout: 0,
|
||||
};
|
||||
133
server/services/analyzer.ts
Normal file
133
server/services/analyzer.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { MediaItem, MediaStream, PlanResult } from '../types';
|
||||
import { normalizeLanguage } from './jellyfin';
|
||||
|
||||
export interface AnalyzerConfig {
|
||||
subtitleLanguages: string[]; // kept for potential future use
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an item and its streams, compute what action to take for each stream
|
||||
* and whether the file needs audio remuxing.
|
||||
*
|
||||
* Subtitles are ALWAYS removed from the container (they get extracted to
|
||||
* sidecar files). is_noop only considers audio changes.
|
||||
*/
|
||||
export function analyzeItem(
|
||||
item: Pick<MediaItem, 'original_language' | 'needs_review'>,
|
||||
streams: MediaStream[],
|
||||
config: AnalyzerConfig
|
||||
): PlanResult {
|
||||
const origLang = item.original_language ? normalizeLanguage(item.original_language) : null;
|
||||
const notes: string[] = [];
|
||||
|
||||
// Compute action for each stream
|
||||
const decisions: PlanResult['decisions'] = streams.map((s) => {
|
||||
const action = decideAction(s, origLang);
|
||||
return { stream_id: s.id, action, target_index: null };
|
||||
});
|
||||
|
||||
// Audio-only noop: only consider audio removals/reordering
|
||||
// (subtitles are always removed from container — that's implicit, not a "change" to review)
|
||||
const anyAudioRemoved = streams.some((s, i) => s.type === 'Audio' && decisions[i].action === 'remove');
|
||||
|
||||
// Compute target ordering for kept streams within type groups
|
||||
const keptStreams = streams.filter((_, i) => decisions[i].action === 'keep');
|
||||
assignTargetOrder(keptStreams, decisions, streams, origLang);
|
||||
|
||||
// Check if audio ordering changes
|
||||
const audioOrderChanged = checkAudioOrderChanged(streams, decisions);
|
||||
|
||||
const isNoop = !anyAudioRemoved && !audioOrderChanged;
|
||||
const hasSubs = streams.some((s) => s.type === 'Subtitle');
|
||||
|
||||
// Generate notes for edge cases
|
||||
if (!origLang && item.needs_review) {
|
||||
notes.push('Original language unknown — audio tracks not filtered; manual review required');
|
||||
}
|
||||
|
||||
return {
|
||||
is_noop: isNoop,
|
||||
has_subs: hasSubs,
|
||||
decisions,
|
||||
notes: notes.length > 0 ? notes.join('\n') : null,
|
||||
};
|
||||
}
|
||||
|
||||
function decideAction(
|
||||
stream: MediaStream,
|
||||
origLang: string | null,
|
||||
): 'keep' | 'remove' {
|
||||
switch (stream.type) {
|
||||
case 'Video':
|
||||
case 'Data':
|
||||
case 'EmbeddedImage':
|
||||
return 'keep';
|
||||
|
||||
case 'Audio': {
|
||||
if (!origLang) return 'keep'; // unknown lang → keep all
|
||||
if (!stream.language) return 'keep'; // undetermined → keep
|
||||
return normalizeLanguage(stream.language) === origLang ? 'keep' : 'remove';
|
||||
}
|
||||
|
||||
case 'Subtitle':
|
||||
// All subtitles are removed from the container and extracted to sidecar files
|
||||
return 'remove';
|
||||
|
||||
default:
|
||||
return 'keep';
|
||||
}
|
||||
}
|
||||
|
||||
function assignTargetOrder(
|
||||
keptStreams: MediaStream[],
|
||||
decisions: PlanResult['decisions'],
|
||||
allStreams: MediaStream[],
|
||||
origLang: string | null
|
||||
): void {
|
||||
// Group kept streams by type
|
||||
const byType: Record<string, MediaStream[]> = {};
|
||||
for (const s of keptStreams) {
|
||||
const t = s.type;
|
||||
byType[t] = byType[t] ?? [];
|
||||
byType[t].push(s);
|
||||
}
|
||||
|
||||
// Sort audio: original lang first, then by stream_index
|
||||
if (byType['Audio']) {
|
||||
byType['Audio'].sort((a, b) => {
|
||||
const aIsOrig = origLang && a.language && normalizeLanguage(a.language) === origLang ? 0 : 1;
|
||||
const bIsOrig = origLang && b.language && normalizeLanguage(b.language) === origLang ? 0 : 1;
|
||||
if (aIsOrig !== bIsOrig) return aIsOrig - bIsOrig;
|
||||
return a.stream_index - b.stream_index;
|
||||
});
|
||||
}
|
||||
|
||||
// Assign target_index per type group
|
||||
for (const [, typeStreams] of Object.entries(byType)) {
|
||||
typeStreams.forEach((s, idx) => {
|
||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||
if (dec) dec.target_index = idx;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if audio stream ordering changes (ignores subtitles which are always removed). */
|
||||
function checkAudioOrderChanged(
|
||||
streams: MediaStream[],
|
||||
decisions: PlanResult['decisions']
|
||||
): boolean {
|
||||
const keptAudio = streams.filter((s) => {
|
||||
if (s.type !== 'Audio') return false;
|
||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||
return dec?.action === 'keep';
|
||||
});
|
||||
|
||||
const sorted = [...keptAudio].sort((a, b) => a.stream_index - b.stream_index);
|
||||
for (let i = 0; i < keptAudio.length; i++) {
|
||||
const dec = decisions.find((d) => d.stream_id === keptAudio[i].id);
|
||||
if (!dec) continue;
|
||||
const currentPos = sorted.findIndex((s) => s.id === keptAudio[i].id);
|
||||
if (dec.target_index !== null && dec.target_index !== currentPos) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
520
server/services/ffmpeg.ts
Normal file
520
server/services/ffmpeg.ts
Normal file
@@ -0,0 +1,520 @@
|
||||
import type { MediaItem, MediaStream, StreamDecision } from '../types';
|
||||
import { normalizeLanguage } from './jellyfin';
|
||||
|
||||
// ─── Subtitle extraction helpers ──────────────────────────────────────────────
|
||||
|
||||
/** ISO 639-2/B → ISO 639-1 two-letter codes for subtitle filenames. */
|
||||
const ISO639_1: Record<string, string> = {
|
||||
eng: 'en', deu: 'de', spa: 'es', fra: 'fr', ita: 'it',
|
||||
por: 'pt', jpn: 'ja', kor: 'ko', zho: 'zh', ara: 'ar',
|
||||
rus: 'ru', nld: 'nl', swe: 'sv', nor: 'no', dan: 'da',
|
||||
fin: 'fi', pol: 'pl', tur: 'tr', tha: 'th', hin: 'hi',
|
||||
hun: 'hu', ces: 'cs', ron: 'ro', ell: 'el', heb: 'he',
|
||||
fas: 'fa', ukr: 'uk', ind: 'id', cat: 'ca', nob: 'nb',
|
||||
nno: 'nn', isl: 'is', hrv: 'hr', slk: 'sk', bul: 'bg',
|
||||
srp: 'sr', slv: 'sl', lav: 'lv', lit: 'lt', est: 'et',
|
||||
};
|
||||
|
||||
/** Subtitle codec → external file extension. */
|
||||
const SUBTITLE_EXT: Record<string, string> = {
|
||||
subrip: 'srt', srt: 'srt', ass: 'ass', ssa: 'ssa',
|
||||
webvtt: 'vtt', vtt: 'vtt',
|
||||
hdmv_pgs_subtitle: 'sup', pgssub: 'sup',
|
||||
dvd_subtitle: 'sub', dvbsub: 'sub',
|
||||
mov_text: 'srt', text: 'srt',
|
||||
};
|
||||
|
||||
function subtitleLang2(lang: string | null): string {
|
||||
if (!lang) return 'und';
|
||||
const n = normalizeLanguage(lang);
|
||||
return ISO639_1[n] ?? n;
|
||||
}
|
||||
|
||||
/** Returns the ffmpeg codec name to use when extracting this subtitle stream. */
|
||||
function subtitleCodecArg(codec: string | null): string {
|
||||
if (!codec) return 'copy';
|
||||
return codec.toLowerCase() === 'mov_text' ? 'subrip' : 'copy';
|
||||
}
|
||||
|
||||
function subtitleExtForCodec(codec: string | null): string {
|
||||
if (!codec) return 'srt';
|
||||
return SUBTITLE_EXT[codec.toLowerCase()] ?? 'srt';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ffmpeg output args for extracting ALL subtitle streams
|
||||
* to external sidecar files next to the video.
|
||||
*
|
||||
* Returns a flat array of args to append after the main output in the
|
||||
* command. Each subtitle becomes a separate ffmpeg output:
|
||||
* -map 0:s:N -c:s copy 'basename.en.srt'
|
||||
*
|
||||
* @param allStreams All streams for the item (needed to compute type-relative indices)
|
||||
* @param basePath Video file path without extension (host or /work path)
|
||||
*/
|
||||
interface ExtractionEntry {
|
||||
stream: MediaStream;
|
||||
typeIdx: number;
|
||||
outPath: string;
|
||||
codecArg: string;
|
||||
}
|
||||
|
||||
/** Compute extraction metadata for all subtitle streams. Shared by buildExtractionOutputs and predictExtractedFiles. */
|
||||
function computeExtractionEntries(
|
||||
allStreams: MediaStream[],
|
||||
basePath: string
|
||||
): ExtractionEntry[] {
|
||||
const subTypeIdx = new Map<number, number>();
|
||||
let subCount = 0;
|
||||
for (const s of [...allStreams].sort((a, b) => a.stream_index - b.stream_index)) {
|
||||
if (s.type === 'Subtitle') subTypeIdx.set(s.id, subCount++);
|
||||
}
|
||||
|
||||
const allSubs = allStreams
|
||||
.filter((s) => s.type === 'Subtitle')
|
||||
.sort((a, b) => a.stream_index - b.stream_index);
|
||||
|
||||
if (allSubs.length === 0) return [];
|
||||
|
||||
const usedNames = new Set<string>();
|
||||
const entries: ExtractionEntry[] = [];
|
||||
|
||||
for (const s of allSubs) {
|
||||
const typeIdx = subTypeIdx.get(s.id) ?? 0;
|
||||
const langCode = subtitleLang2(s.language);
|
||||
const ext = subtitleExtForCodec(s.codec);
|
||||
const codecArg = subtitleCodecArg(s.codec);
|
||||
|
||||
const nameParts = [langCode];
|
||||
if (s.is_forced) nameParts.push('forced');
|
||||
if (s.is_hearing_impaired) nameParts.push('hi');
|
||||
|
||||
let outPath = `${basePath}.${nameParts.join('.')}.${ext}`;
|
||||
let counter = 2;
|
||||
while (usedNames.has(outPath)) {
|
||||
outPath = `${basePath}.${nameParts.join('.')}.${counter}.${ext}`;
|
||||
counter++;
|
||||
}
|
||||
usedNames.add(outPath);
|
||||
|
||||
entries.push({ stream: s, typeIdx, outPath, codecArg });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function buildExtractionOutputs(
|
||||
allStreams: MediaStream[],
|
||||
basePath: string
|
||||
): string[] {
|
||||
const entries = computeExtractionEntries(allStreams, basePath);
|
||||
const args: string[] = [];
|
||||
for (const e of entries) {
|
||||
args.push(`-map 0:s:${e.typeIdx}`, `-c:s ${e.codecArg}`, shellQuote(e.outPath));
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Predict the sidecar files that subtitle extraction will create.
|
||||
* Used to populate the subtitle_files table after a successful job.
|
||||
*/
|
||||
export function predictExtractedFiles(
|
||||
item: MediaItem,
|
||||
streams: MediaStream[]
|
||||
): Array<{ file_path: string; language: string | null; codec: string | null; is_forced: boolean; is_hearing_impaired: boolean }> {
|
||||
const basePath = item.file_path.replace(/\.[^.]+$/, '');
|
||||
const entries = computeExtractionEntries(streams, basePath);
|
||||
return entries.map((e) => ({
|
||||
file_path: e.outPath,
|
||||
language: e.stream.language,
|
||||
codec: e.stream.codec,
|
||||
is_forced: !!e.stream.is_forced,
|
||||
is_hearing_impaired: !!e.stream.is_hearing_impaired,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const LANG_NAMES: Record<string, string> = {
|
||||
eng: 'English', deu: 'German', spa: 'Spanish', fra: 'French',
|
||||
ita: 'Italian', por: 'Portuguese', jpn: 'Japanese', kor: 'Korean',
|
||||
zho: 'Chinese', ara: 'Arabic', rus: 'Russian', nld: 'Dutch',
|
||||
swe: 'Swedish', nor: 'Norwegian', dan: 'Danish', fin: 'Finnish',
|
||||
pol: 'Polish', tur: 'Turkish', tha: 'Thai', hin: 'Hindi',
|
||||
hun: 'Hungarian', ces: 'Czech', ron: 'Romanian', ell: 'Greek',
|
||||
heb: 'Hebrew', fas: 'Persian', ukr: 'Ukrainian', ind: 'Indonesian',
|
||||
cat: 'Catalan', nob: 'Norwegian Bokmål', nno: 'Norwegian Nynorsk',
|
||||
isl: 'Icelandic', slk: 'Slovak', hrv: 'Croatian', bul: 'Bulgarian',
|
||||
srp: 'Serbian', slv: 'Slovenian', lav: 'Latvian', lit: 'Lithuanian',
|
||||
est: 'Estonian',
|
||||
};
|
||||
|
||||
function trackTitle(stream: MediaStream): string | null {
|
||||
if (stream.type === 'Subtitle') {
|
||||
// Subtitles always get a clean language-based title so Jellyfin displays
|
||||
// "German", "English (Forced)", etc. regardless of the original file title.
|
||||
// The review UI shows a ⚠ badge when the original title looks like a
|
||||
// different language, so users can spot and remove mislabeled tracks.
|
||||
if (!stream.language) return null;
|
||||
const lang = normalizeLanguage(stream.language);
|
||||
const base = LANG_NAMES[lang] ?? lang.toUpperCase();
|
||||
if (stream.is_forced) return `${base} (Forced)`;
|
||||
if (stream.is_hearing_impaired) return `${base} (CC)`;
|
||||
return base;
|
||||
}
|
||||
// For audio and other stream types: preserve any existing title
|
||||
// (e.g. "Director's Commentary") and fall back to language name.
|
||||
if (stream.title) return stream.title;
|
||||
if (!stream.language) return null;
|
||||
const lang = normalizeLanguage(stream.language);
|
||||
return LANG_NAMES[lang] ?? lang.toUpperCase();
|
||||
}
|
||||
|
||||
const TYPE_SPEC: Record<string, string> = { Video: 'v', Audio: 'a', Subtitle: 's' };
|
||||
|
||||
/**
|
||||
* Build -map flags using type-relative specifiers (0:v:N, 0:a:N, 0:s:N).
|
||||
*
|
||||
* Jellyfin's stream_index is an absolute index that can include EmbeddedImage
|
||||
* and Data streams which ffmpeg may count differently (e.g. cover art stored
|
||||
* as attachments). Using the stream's position within its own type group
|
||||
* matches ffmpeg's 0:a:N convention exactly and avoids silent mismatches.
|
||||
*/
|
||||
function buildMaps(
|
||||
allStreams: MediaStream[],
|
||||
kept: { stream: MediaStream; dec: StreamDecision }[]
|
||||
): string[] {
|
||||
// Map each stream id → its 0-based position among streams of the same type,
|
||||
// sorted by stream_index (the order ffmpeg sees them in the input).
|
||||
const typePos = new Map<number, number>();
|
||||
const counts: Record<string, number> = {};
|
||||
for (const s of [...allStreams].sort((a, b) => a.stream_index - b.stream_index)) {
|
||||
if (!TYPE_SPEC[s.type]) continue;
|
||||
const n = counts[s.type] ?? 0;
|
||||
typePos.set(s.id, n);
|
||||
counts[s.type] = n + 1;
|
||||
}
|
||||
|
||||
return kept
|
||||
.filter((k) => !!TYPE_SPEC[k.stream.type])
|
||||
.map((k) => `-map 0:${TYPE_SPEC[k.stream.type]}:${typePos.get(k.stream.id) ?? 0}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build disposition and metadata flags for kept audio + subtitle streams.
|
||||
* - Marks the first kept audio stream as default, clears all others.
|
||||
* - Sets harmonized language-name titles on all kept audio/subtitle streams.
|
||||
*/
|
||||
function buildStreamFlags(
|
||||
kept: { stream: MediaStream; dec: StreamDecision }[]
|
||||
): string[] {
|
||||
const audioKept = kept.filter((k) => k.stream.type === 'Audio');
|
||||
const subKept = kept.filter((k) => k.stream.type === 'Subtitle');
|
||||
const args: string[] = [];
|
||||
|
||||
// Disposition: first audio = default, rest = clear
|
||||
audioKept.forEach((_, i) => {
|
||||
args.push(`-disposition:a:${i}`, i === 0 ? 'default' : '0');
|
||||
});
|
||||
|
||||
// Titles for audio streams (custom_title overrides generated title)
|
||||
audioKept.forEach((k, i) => {
|
||||
const title = k.dec.custom_title ?? trackTitle(k.stream);
|
||||
if (title) args.push(`-metadata:s:a:${i}`, `title=${shellQuote(title)}`);
|
||||
});
|
||||
|
||||
// Titles for subtitle streams (custom_title overrides generated title)
|
||||
subKept.forEach((k, i) => {
|
||||
const title = k.dec.custom_title ?? trackTitle(k.stream);
|
||||
if (title) args.push(`-metadata:s:s:${i}`, `title=${shellQuote(title)}`);
|
||||
});
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full shell command to remux a media file, keeping only the
|
||||
* streams specified by the decisions and in the target order.
|
||||
*
|
||||
* Returns null if all streams are kept and ordering is unchanged (noop).
|
||||
*/
|
||||
export function buildCommand(
|
||||
item: MediaItem,
|
||||
streams: MediaStream[],
|
||||
decisions: StreamDecision[]
|
||||
): string {
|
||||
// Sort kept streams by type priority then target_index
|
||||
const kept = streams
|
||||
.map((s) => {
|
||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||
return dec?.action === 'keep' ? { stream: s, dec } : null;
|
||||
})
|
||||
.filter(Boolean) as { stream: MediaStream; dec: StreamDecision }[];
|
||||
|
||||
// Sort: Video first, Audio second, Subtitle third, Data last
|
||||
const typeOrder: Record<string, number> = { Video: 0, Audio: 1, Subtitle: 2, Data: 3, EmbeddedImage: 4 };
|
||||
kept.sort((a, b) => {
|
||||
const ta = typeOrder[a.stream.type] ?? 9;
|
||||
const tb = typeOrder[b.stream.type] ?? 9;
|
||||
if (ta !== tb) return ta - tb;
|
||||
return (a.dec.target_index ?? 0) - (b.dec.target_index ?? 0);
|
||||
});
|
||||
|
||||
const inputPath = item.file_path;
|
||||
const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv';
|
||||
const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
|
||||
const basePath = inputPath.replace(/\.[^.]+$/, '');
|
||||
|
||||
const maps = buildMaps(streams, kept);
|
||||
const streamFlags = buildStreamFlags(kept);
|
||||
const extractionOutputs = buildExtractionOutputs(streams, basePath);
|
||||
|
||||
const parts: string[] = [
|
||||
'ffmpeg',
|
||||
'-y',
|
||||
'-i', shellQuote(inputPath),
|
||||
...maps,
|
||||
...streamFlags,
|
||||
'-c copy',
|
||||
shellQuote(tmpPath),
|
||||
...extractionOutputs,
|
||||
'&&',
|
||||
'mv', shellQuote(tmpPath), shellQuote(inputPath),
|
||||
];
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a command that also changes the container to MKV.
|
||||
* Used when MP4 container can't hold certain subtitle codecs.
|
||||
*/
|
||||
export function buildMkvConvertCommand(
|
||||
item: MediaItem,
|
||||
streams: MediaStream[],
|
||||
decisions: StreamDecision[]
|
||||
): string {
|
||||
const inputPath = item.file_path;
|
||||
const outputPath = inputPath.replace(/\.[^.]+$/, '.mkv');
|
||||
const tmpPath = inputPath.replace(/\.[^.]+$/, '.tmp.mkv');
|
||||
const basePath = outputPath.replace(/\.[^.]+$/, '');
|
||||
|
||||
const kept = streams
|
||||
.map((s) => {
|
||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||
return dec?.action === 'keep' ? { stream: s, dec } : null;
|
||||
})
|
||||
.filter(Boolean) as { stream: MediaStream; dec: StreamDecision }[];
|
||||
|
||||
const typeOrder: Record<string, number> = { Video: 0, Audio: 1, Subtitle: 2, Data: 3 };
|
||||
kept.sort((a, b) => {
|
||||
const ta = typeOrder[a.stream.type] ?? 9;
|
||||
const tb = typeOrder[b.stream.type] ?? 9;
|
||||
if (ta !== tb) return ta - tb;
|
||||
return (a.dec.target_index ?? 0) - (b.dec.target_index ?? 0);
|
||||
});
|
||||
|
||||
const maps = buildMaps(streams, kept);
|
||||
const streamFlags = buildStreamFlags(kept);
|
||||
const extractionOutputs = buildExtractionOutputs(streams, basePath);
|
||||
|
||||
return [
|
||||
'ffmpeg', '-y',
|
||||
'-i', shellQuote(inputPath),
|
||||
...maps,
|
||||
...streamFlags,
|
||||
'-c copy',
|
||||
'-f matroska',
|
||||
shellQuote(tmpPath),
|
||||
...extractionOutputs,
|
||||
'&&',
|
||||
'mv', shellQuote(tmpPath), shellQuote(outputPath),
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Docker-wrapped version of the FFmpeg command.
|
||||
* Mounts the file's directory to /work inside the container and rewrites
|
||||
* all paths accordingly. Requires only Docker as a system dependency.
|
||||
*
|
||||
* Image: jrottenberg/ffmpeg — entrypoint is ffmpeg, so we use --entrypoint sh
|
||||
* to run ffmpeg + mv in a single shell invocation.
|
||||
*/
|
||||
export function buildDockerCommand(
|
||||
item: MediaItem,
|
||||
streams: MediaStream[],
|
||||
decisions: StreamDecision[],
|
||||
opts: { moviesPath?: string; seriesPath?: string } = {}
|
||||
): { command: string; mountDir: string } {
|
||||
const inputPath = item.file_path;
|
||||
const isEpisode = item.type === 'Episode';
|
||||
|
||||
let mountDir: string;
|
||||
let relPath: string;
|
||||
|
||||
const hostRoot = (isEpisode ? opts.seriesPath : opts.moviesPath)?.replace(/\/$/, '') ?? '';
|
||||
// Jellyfin always mounts libraries at /movies and /series by convention
|
||||
const jellyfinPrefix = isEpisode ? '/series' : '/movies';
|
||||
|
||||
if (hostRoot) {
|
||||
mountDir = hostRoot;
|
||||
if (inputPath.startsWith(jellyfinPrefix + '/')) {
|
||||
relPath = inputPath.slice(jellyfinPrefix.length); // keeps leading /
|
||||
} else {
|
||||
// Path doesn't match the expected prefix — strip 1 component as best effort
|
||||
const components = inputPath.split('/').filter(Boolean);
|
||||
relPath = '/' + components.slice(1).join('/');
|
||||
}
|
||||
} else {
|
||||
// No host path configured — fall back to mounting the file's immediate parent directory
|
||||
const lastSlash = inputPath.lastIndexOf('/');
|
||||
mountDir = lastSlash >= 0 ? inputPath.slice(0, lastSlash) : '.';
|
||||
relPath = '/' + (lastSlash >= 0 ? inputPath.slice(lastSlash + 1) : inputPath);
|
||||
}
|
||||
|
||||
const ext = relPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv';
|
||||
const tmpRelPath = relPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
|
||||
|
||||
const workInput = `/work${relPath}`;
|
||||
const workTmp = `/work${tmpRelPath}`;
|
||||
const workBasePath = workInput.replace(/\.[^.]+$/, '');
|
||||
|
||||
const kept = streams
|
||||
.map((s) => {
|
||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||
return dec?.action === 'keep' ? { stream: s, dec } : null;
|
||||
})
|
||||
.filter(Boolean) as { stream: MediaStream; dec: StreamDecision }[];
|
||||
|
||||
const typeOrder: Record<string, number> = { Video: 0, Audio: 1, Subtitle: 2, Data: 3, EmbeddedImage: 4 };
|
||||
kept.sort((a, b) => {
|
||||
const ta = typeOrder[a.stream.type] ?? 9;
|
||||
const tb = typeOrder[b.stream.type] ?? 9;
|
||||
if (ta !== tb) return ta - tb;
|
||||
return (a.dec.target_index ?? 0) - (b.dec.target_index ?? 0);
|
||||
});
|
||||
|
||||
const maps = buildMaps(streams, kept);
|
||||
const streamFlags = buildStreamFlags(kept);
|
||||
// Subtitle extraction uses /work paths so files land in the mounted directory
|
||||
const extractionOutputs = buildExtractionOutputs(streams, workBasePath);
|
||||
|
||||
// The jrottenberg/ffmpeg entrypoint IS ffmpeg — run it directly so no inner
|
||||
// shell is needed and no nested quoting is required. The mv step runs on the
|
||||
// host (outside Docker) so it uses the real host paths.
|
||||
const hostInput = mountDir + relPath;
|
||||
const hostTmp = mountDir + tmpRelPath;
|
||||
|
||||
const parts = [
|
||||
'docker run --rm',
|
||||
`-v ${shellQuote(mountDir + ':/work')}`,
|
||||
'jrottenberg/ffmpeg:latest',
|
||||
'-y',
|
||||
'-i', shellQuote(workInput),
|
||||
...maps,
|
||||
...streamFlags,
|
||||
'-c copy',
|
||||
shellQuote(workTmp),
|
||||
...extractionOutputs,
|
||||
'&&',
|
||||
'mv', shellQuote(hostTmp), shellQuote(hostInput),
|
||||
];
|
||||
|
||||
return { command: parts.join(' '), mountDir };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a command that ONLY extracts subtitles to sidecar files
|
||||
* without modifying the container. Useful when the item is otherwise
|
||||
* a noop but the user wants sidecar subtitle files.
|
||||
*/
|
||||
export function buildExtractOnlyCommand(
|
||||
item: MediaItem,
|
||||
streams: MediaStream[]
|
||||
): string | null {
|
||||
const basePath = item.file_path.replace(/\.[^.]+$/, '');
|
||||
const extractionOutputs = buildExtractionOutputs(streams, basePath);
|
||||
if (extractionOutputs.length === 0) return null;
|
||||
return ['ffmpeg', '-y', '-i', shellQuote(item.file_path), ...extractionOutputs].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Docker command that ONLY extracts subtitles to sidecar files.
|
||||
*/
|
||||
export function buildDockerExtractOnlyCommand(
|
||||
item: MediaItem,
|
||||
streams: MediaStream[],
|
||||
opts: { moviesPath?: string; seriesPath?: string } = {}
|
||||
): { command: string; mountDir: string } | null {
|
||||
const inputPath = item.file_path;
|
||||
const isEpisode = item.type === 'Episode';
|
||||
|
||||
let mountDir: string;
|
||||
let relPath: string;
|
||||
|
||||
const hostRoot = (isEpisode ? opts.seriesPath : opts.moviesPath)?.replace(/\/$/, '') ?? '';
|
||||
const jellyfinPrefix = isEpisode ? '/series' : '/movies';
|
||||
|
||||
if (hostRoot) {
|
||||
mountDir = hostRoot;
|
||||
if (inputPath.startsWith(jellyfinPrefix + '/')) {
|
||||
relPath = inputPath.slice(jellyfinPrefix.length);
|
||||
} else {
|
||||
const components = inputPath.split('/').filter(Boolean);
|
||||
relPath = '/' + components.slice(1).join('/');
|
||||
}
|
||||
} else {
|
||||
const lastSlash = inputPath.lastIndexOf('/');
|
||||
mountDir = lastSlash >= 0 ? inputPath.slice(0, lastSlash) : '.';
|
||||
relPath = '/' + (lastSlash >= 0 ? inputPath.slice(lastSlash + 1) : inputPath);
|
||||
}
|
||||
|
||||
const workInput = `/work${relPath}`;
|
||||
const workBasePath = workInput.replace(/\.[^.]+$/, '');
|
||||
const extractionOutputs = buildExtractionOutputs(streams, workBasePath);
|
||||
if (extractionOutputs.length === 0) return null;
|
||||
|
||||
const parts = [
|
||||
'docker run --rm',
|
||||
`-v ${shellQuote(mountDir + ':/work')}`,
|
||||
'jrottenberg/ffmpeg:latest',
|
||||
'-y',
|
||||
'-i', shellQuote(workInput),
|
||||
...extractionOutputs,
|
||||
];
|
||||
|
||||
return { command: parts.join(' '), mountDir };
|
||||
}
|
||||
|
||||
/** Safely quote a path for shell usage. */
|
||||
export function shellQuote(s: string): string {
|
||||
return `'${s.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
/** Returns a human-readable summary of what will change. */
|
||||
export function summarizeChanges(
|
||||
streams: MediaStream[],
|
||||
decisions: StreamDecision[]
|
||||
): { removed: MediaStream[]; kept: MediaStream[] } {
|
||||
const removed: MediaStream[] = [];
|
||||
const kept: MediaStream[] = [];
|
||||
for (const s of streams) {
|
||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||
if (!dec || dec.action === 'remove') removed.push(s);
|
||||
else kept.push(s);
|
||||
}
|
||||
return { removed, kept };
|
||||
}
|
||||
|
||||
/** Format a stream for display. */
|
||||
export function streamLabel(s: MediaStream): string {
|
||||
const parts: string[] = [s.type];
|
||||
if (s.codec) parts.push(s.codec);
|
||||
if (s.language_display || s.language) parts.push(s.language_display ?? s.language!);
|
||||
if (s.title) parts.push(`"${s.title}"`);
|
||||
if (s.type === 'Audio' && s.channels) parts.push(`${s.channels}ch`);
|
||||
if (s.is_forced) parts.push('forced');
|
||||
if (s.is_hearing_impaired) parts.push('CC');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
244
server/services/jellyfin.ts
Normal file
244
server/services/jellyfin.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { JellyfinItem, JellyfinMediaStream, JellyfinUser, MediaStream } from '../types';
|
||||
|
||||
export interface JellyfinConfig {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
/** Optional: when omitted the server-level /Items endpoint is used (requires admin API key). */
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
/** Build the base items URL: user-scoped when userId is set, server-level otherwise. */
|
||||
function itemsBaseUrl(cfg: JellyfinConfig): string {
|
||||
return cfg.userId ? `${cfg.url}/Users/${cfg.userId}/Items` : `${cfg.url}/Items`;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
function headers(apiKey: string): Record<string, string> {
|
||||
return {
|
||||
'X-Emby-Token': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
export async function testConnection(cfg: JellyfinConfig): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const res = await fetch(`${cfg.url}/Users`, {
|
||||
headers: headers(cfg.apiKey),
|
||||
});
|
||||
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUsers(cfg: Pick<JellyfinConfig, 'url' | 'apiKey'>): Promise<JellyfinUser[]> {
|
||||
const res = await fetch(`${cfg.url}/Users`, { headers: headers(cfg.apiKey) });
|
||||
if (!res.ok) throw new Error(`Jellyfin /Users failed: ${res.status}`);
|
||||
return res.json() as Promise<JellyfinUser[]>;
|
||||
}
|
||||
|
||||
const ITEM_FIELDS = [
|
||||
'MediaStreams',
|
||||
'Path',
|
||||
'ProviderIds',
|
||||
'OriginalTitle',
|
||||
'ProductionYear',
|
||||
'Size',
|
||||
'Container',
|
||||
].join(',');
|
||||
|
||||
export async function* getAllItems(
|
||||
cfg: JellyfinConfig,
|
||||
onProgress?: (count: number, total: number) => void
|
||||
): AsyncGenerator<JellyfinItem> {
|
||||
let startIndex = 0;
|
||||
let total = 0;
|
||||
|
||||
do {
|
||||
const url = new URL(itemsBaseUrl(cfg));
|
||||
url.searchParams.set('Recursive', 'true');
|
||||
url.searchParams.set('IncludeItemTypes', 'Movie,Episode');
|
||||
url.searchParams.set('Fields', ITEM_FIELDS);
|
||||
url.searchParams.set('Limit', String(PAGE_SIZE));
|
||||
url.searchParams.set('StartIndex', String(startIndex));
|
||||
|
||||
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (!res.ok) throw new Error(`Jellyfin items failed: ${res.status}`);
|
||||
|
||||
const body = (await res.json()) as { Items: JellyfinItem[]; TotalRecordCount: number };
|
||||
total = body.TotalRecordCount;
|
||||
|
||||
for (const item of body.Items) {
|
||||
yield item;
|
||||
}
|
||||
|
||||
startIndex += body.Items.length;
|
||||
onProgress?.(startIndex, total);
|
||||
} while (startIndex < total);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev mode: yields 50 random movies + all episodes from 10 random series.
|
||||
* Used instead of getAllItems() when NODE_ENV=development.
|
||||
*/
|
||||
export async function* getDevItems(cfg: JellyfinConfig): AsyncGenerator<JellyfinItem> {
|
||||
// 50 random movies
|
||||
const movieUrl = new URL(itemsBaseUrl(cfg));
|
||||
movieUrl.searchParams.set('Recursive', 'true');
|
||||
movieUrl.searchParams.set('IncludeItemTypes', 'Movie');
|
||||
movieUrl.searchParams.set('SortBy', 'Random');
|
||||
movieUrl.searchParams.set('Limit', '50');
|
||||
movieUrl.searchParams.set('Fields', ITEM_FIELDS);
|
||||
|
||||
const movieRes = await fetch(movieUrl.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (!movieRes.ok) throw new Error(`Jellyfin movies failed: HTTP ${movieRes.status} — check JELLYFIN_URL and JELLYFIN_API_KEY`);
|
||||
const movieBody = (await movieRes.json()) as { Items: JellyfinItem[] };
|
||||
for (const item of movieBody.Items) yield item;
|
||||
|
||||
// 10 random series → yield all their episodes
|
||||
const seriesUrl = new URL(itemsBaseUrl(cfg));
|
||||
seriesUrl.searchParams.set('Recursive', 'true');
|
||||
seriesUrl.searchParams.set('IncludeItemTypes', 'Series');
|
||||
seriesUrl.searchParams.set('SortBy', 'Random');
|
||||
seriesUrl.searchParams.set('Limit', '10');
|
||||
|
||||
const seriesRes = await fetch(seriesUrl.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (!seriesRes.ok) throw new Error(`Jellyfin series failed: HTTP ${seriesRes.status}`);
|
||||
const seriesBody = (await seriesRes.json()) as { Items: Array<{ Id: string }> };
|
||||
for (const series of seriesBody.Items) {
|
||||
const epUrl = new URL(itemsBaseUrl(cfg));
|
||||
epUrl.searchParams.set('ParentId', series.Id);
|
||||
epUrl.searchParams.set('Recursive', 'true');
|
||||
epUrl.searchParams.set('IncludeItemTypes', 'Episode');
|
||||
epUrl.searchParams.set('Fields', ITEM_FIELDS);
|
||||
|
||||
const epRes = await fetch(epUrl.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (epRes.ok) {
|
||||
const epBody = (await epRes.json()) as { Items: JellyfinItem[] };
|
||||
for (const ep of epBody.Items) yield ep;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch a single Jellyfin item by its ID (for per-file rescan). */
|
||||
export async function getItem(cfg: JellyfinConfig, jellyfinId: string): Promise<JellyfinItem | null> {
|
||||
const base = cfg.userId ? `${cfg.url}/Users/${cfg.userId}/Items/${jellyfinId}` : `${cfg.url}/Items/${jellyfinId}`;
|
||||
const url = new URL(base);
|
||||
url.searchParams.set('Fields', ITEM_FIELDS);
|
||||
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<JellyfinItem>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a Jellyfin metadata refresh for a single item and wait until it completes.
|
||||
* Polls DateLastRefreshed until it changes (or timeout is reached).
|
||||
*/
|
||||
export async function refreshItem(cfg: JellyfinConfig, jellyfinId: string, timeoutMs = 15000): Promise<void> {
|
||||
const itemUrl = `${cfg.url}/Items/${jellyfinId}`;
|
||||
|
||||
// 1. Snapshot current DateLastRefreshed
|
||||
const beforeRes = await fetch(itemUrl, { headers: headers(cfg.apiKey) });
|
||||
if (!beforeRes.ok) throw new Error(`Jellyfin item fetch failed: HTTP ${beforeRes.status}`);
|
||||
const before = (await beforeRes.json()) as { DateLastRefreshed?: string };
|
||||
const beforeDate = before.DateLastRefreshed;
|
||||
|
||||
// 2. Trigger refresh (returns 204 immediately; refresh runs async)
|
||||
const refreshUrl = new URL(`${itemUrl}/Refresh`);
|
||||
refreshUrl.searchParams.set('MetadataRefreshMode', 'FullRefresh');
|
||||
refreshUrl.searchParams.set('ImageRefreshMode', 'None');
|
||||
refreshUrl.searchParams.set('ReplaceAllMetadata', 'false');
|
||||
refreshUrl.searchParams.set('ReplaceAllImages', 'false');
|
||||
const refreshRes = await fetch(refreshUrl.toString(), { method: 'POST', headers: headers(cfg.apiKey) });
|
||||
if (!refreshRes.ok) throw new Error(`Jellyfin refresh failed: HTTP ${refreshRes.status}`);
|
||||
|
||||
// 3. Poll until DateLastRefreshed changes
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const checkRes = await fetch(itemUrl, { headers: headers(cfg.apiKey) });
|
||||
if (!checkRes.ok) continue;
|
||||
const check = (await checkRes.json()) as { DateLastRefreshed?: string };
|
||||
if (check.DateLastRefreshed && check.DateLastRefreshed !== beforeDate) return;
|
||||
}
|
||||
// Timeout reached — proceed anyway (refresh may still complete in background)
|
||||
}
|
||||
|
||||
/** Map a Jellyfin item to our normalized language code (ISO 639-2). */
|
||||
export function extractOriginalLanguage(item: JellyfinItem): string | null {
|
||||
// Jellyfin doesn't have a direct "original_language" field like TMDb.
|
||||
// The best proxy is the language of the first audio stream.
|
||||
if (!item.MediaStreams) return null;
|
||||
const firstAudio = item.MediaStreams.find((s) => s.Type === 'Audio');
|
||||
return firstAudio?.Language ? normalizeLanguage(firstAudio.Language) : null;
|
||||
}
|
||||
|
||||
/** Map a Jellyfin MediaStream to our internal MediaStream shape (sans id/item_id). */
|
||||
export function mapStream(s: JellyfinMediaStream): Omit<MediaStream, 'id' | 'item_id'> {
|
||||
return {
|
||||
stream_index: s.Index,
|
||||
type: s.Type as MediaStream['type'],
|
||||
codec: s.Codec ?? null,
|
||||
language: s.Language ? normalizeLanguage(s.Language) : null,
|
||||
language_display: s.DisplayLanguage ?? null,
|
||||
title: s.Title ?? null,
|
||||
is_default: s.IsDefault ? 1 : 0,
|
||||
is_forced: s.IsForced ? 1 : 0,
|
||||
is_hearing_impaired: s.IsHearingImpaired ? 1 : 0,
|
||||
channels: s.Channels ?? null,
|
||||
channel_layout: s.ChannelLayout ?? null,
|
||||
bit_rate: s.BitRate ?? null,
|
||||
sample_rate: s.SampleRate ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ISO 639-2/T → ISO 639-2/B normalization + common aliases
|
||||
const LANG_ALIASES: Record<string, string> = {
|
||||
// German: both /T (deu) and /B (ger) → deu
|
||||
ger: 'deu',
|
||||
// Chinese
|
||||
chi: 'zho',
|
||||
// French
|
||||
fre: 'fra',
|
||||
// Dutch
|
||||
dut: 'nld',
|
||||
// Modern Greek
|
||||
gre: 'ell',
|
||||
// Hebrew
|
||||
heb: 'heb',
|
||||
// Farsi
|
||||
per: 'fas',
|
||||
// Romanian
|
||||
rum: 'ron',
|
||||
// Malay
|
||||
may: 'msa',
|
||||
// Tibetan
|
||||
tib: 'bod',
|
||||
// Burmese
|
||||
bur: 'mya',
|
||||
// Czech
|
||||
cze: 'ces',
|
||||
// Slovak
|
||||
slo: 'slk',
|
||||
// Georgian
|
||||
geo: 'kat',
|
||||
// Icelandic
|
||||
ice: 'isl',
|
||||
// Armenian
|
||||
arm: 'hye',
|
||||
// Basque
|
||||
baq: 'eus',
|
||||
// Albanian
|
||||
alb: 'sqi',
|
||||
// Macedonian
|
||||
mac: 'mkd',
|
||||
// Welsh
|
||||
wel: 'cym',
|
||||
};
|
||||
|
||||
export function normalizeLanguage(lang: string): string {
|
||||
const lower = lang.toLowerCase().trim();
|
||||
return LANG_ALIASES[lower] ?? lower;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,255 +0,0 @@
|
||||
import { Hono } from 'hono';
|
||||
import { stream } from 'hono/streaming';
|
||||
import { getDb } from '../db/index';
|
||||
import { execStream, execOnce } from '../services/ssh';
|
||||
import type { Job, Node, MediaItem } from '../types';
|
||||
import { ExecutePage } from '../views/execute';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// ─── SSE state ────────────────────────────────────────────────────────────────
|
||||
|
||||
const jobListeners = new Set<(data: string) => void>();
|
||||
|
||||
function emitJobUpdate(jobId: number, status: string, output?: string): void {
|
||||
const line = `event: job_update\ndata: ${JSON.stringify({ id: jobId, status, output })}\n\n`;
|
||||
for (const l of jobListeners) l(line);
|
||||
}
|
||||
|
||||
// ─── List page ────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/', (c) => {
|
||||
const db = getDb();
|
||||
const jobRows = db.prepare(`
|
||||
SELECT j.*, mi.name, mi.type, mi.series_name, mi.season_number, mi.episode_number,
|
||||
mi.file_path,
|
||||
n.name as node_name, n.host, n.port, n.username,
|
||||
n.private_key, n.ffmpeg_path, n.work_dir, n.status as node_status
|
||||
FROM jobs j
|
||||
LEFT JOIN media_items mi ON mi.id = j.item_id
|
||||
LEFT JOIN nodes n ON n.id = j.node_id
|
||||
ORDER BY j.created_at DESC
|
||||
LIMIT 200
|
||||
`).all() as (Job & {
|
||||
name: string;
|
||||
type: string;
|
||||
series_name: string | null;
|
||||
season_number: number | null;
|
||||
episode_number: number | null;
|
||||
file_path: string;
|
||||
node_name: string | null;
|
||||
host: string | null;
|
||||
port: number | null;
|
||||
username: string | null;
|
||||
private_key: string | null;
|
||||
ffmpeg_path: string | null;
|
||||
work_dir: string | null;
|
||||
node_status: string | null;
|
||||
})[];
|
||||
|
||||
const jobs = jobRows.map((r) => ({
|
||||
job: r as unknown as Job,
|
||||
item: r.name ? {
|
||||
id: r.item_id,
|
||||
name: r.name,
|
||||
type: r.type,
|
||||
series_name: r.series_name,
|
||||
season_number: r.season_number,
|
||||
episode_number: r.episode_number,
|
||||
file_path: r.file_path,
|
||||
} as unknown as MediaItem : null,
|
||||
node: r.node_name ? {
|
||||
id: r.node_id!,
|
||||
name: r.node_name,
|
||||
host: r.host!,
|
||||
port: r.port!,
|
||||
username: r.username!,
|
||||
private_key: r.private_key!,
|
||||
ffmpeg_path: r.ffmpeg_path!,
|
||||
work_dir: r.work_dir!,
|
||||
status: r.node_status!,
|
||||
} as unknown as Node : null,
|
||||
}));
|
||||
|
||||
const nodes = db.prepare('SELECT * FROM nodes ORDER BY name').all() as Node[];
|
||||
|
||||
return c.html(<ExecutePage jobs={jobs} nodes={nodes} />);
|
||||
});
|
||||
|
||||
// ─── Start all pending ────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/start', (c) => {
|
||||
const db = getDb();
|
||||
const pending = db.prepare(
|
||||
"SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at"
|
||||
).all() as Job[];
|
||||
|
||||
for (const job of pending) {
|
||||
runJob(job).catch((err) => console.error(`Job ${job.id} failed:`, err));
|
||||
}
|
||||
|
||||
return c.redirect('/execute');
|
||||
});
|
||||
|
||||
// ─── Assign node ──────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/job/:id/assign', async (c) => {
|
||||
const db = getDb();
|
||||
const jobId = Number(c.req.param('id'));
|
||||
const body = await c.req.formData();
|
||||
const nodeId = body.get('node_id') ? Number(body.get('node_id')) : null;
|
||||
|
||||
db.prepare('UPDATE jobs SET node_id = ? WHERE id = ?').run(nodeId, jobId);
|
||||
return c.redirect('/execute');
|
||||
});
|
||||
|
||||
// ─── Run single job ───────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/job/:id/run', async (c) => {
|
||||
const db = getDb();
|
||||
const jobId = Number(c.req.param('id'));
|
||||
const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(jobId) as Job | undefined;
|
||||
|
||||
if (!job || job.status !== 'pending') {
|
||||
return c.redirect('/execute');
|
||||
}
|
||||
|
||||
runJob(job).catch((err) => console.error(`Job ${job.id} failed:`, err));
|
||||
return c.redirect('/execute');
|
||||
});
|
||||
|
||||
// ─── Cancel job ───────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/job/:id/cancel', (c) => {
|
||||
const db = getDb();
|
||||
const jobId = Number(c.req.param('id'));
|
||||
db.prepare("DELETE FROM jobs WHERE id = ? AND status = 'pending'").run(jobId);
|
||||
return c.redirect('/execute');
|
||||
});
|
||||
|
||||
// ─── SSE ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/events', (c) => {
|
||||
return stream(c, async (s) => {
|
||||
c.header('Content-Type', 'text/event-stream');
|
||||
c.header('Cache-Control', 'no-cache');
|
||||
|
||||
const queue: string[] = [];
|
||||
let resolve: (() => void) | null = null;
|
||||
|
||||
const listener = (data: string) => {
|
||||
queue.push(data);
|
||||
resolve?.();
|
||||
};
|
||||
|
||||
jobListeners.add(listener);
|
||||
s.onAbort(() => { jobListeners.delete(listener); });
|
||||
|
||||
try {
|
||||
while (!s.closed) {
|
||||
if (queue.length > 0) {
|
||||
await s.write(queue.shift()!);
|
||||
} else {
|
||||
await new Promise<void>((res) => {
|
||||
resolve = res;
|
||||
setTimeout(res, 15_000);
|
||||
});
|
||||
resolve = null;
|
||||
if (queue.length === 0) await s.write(': keepalive\n\n');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
jobListeners.delete(listener);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Job execution ────────────────────────────────────────────────────────────
|
||||
|
||||
async function runJob(job: Job): Promise<void> {
|
||||
const db = getDb();
|
||||
|
||||
db.prepare(
|
||||
"UPDATE jobs SET status = 'running', started_at = datetime('now'), output = '' WHERE id = ?"
|
||||
).run(job.id);
|
||||
emitJobUpdate(job.id, 'running');
|
||||
|
||||
let outputLines: string[] = [];
|
||||
|
||||
try {
|
||||
if (job.node_id) {
|
||||
// Remote execution
|
||||
const node = db.prepare('SELECT * FROM nodes WHERE id = ?').get(job.node_id) as Node | undefined;
|
||||
if (!node) throw new Error(`Node ${job.node_id} not found`);
|
||||
|
||||
for await (const line of execStream(node, job.command)) {
|
||||
outputLines.push(line);
|
||||
// Flush to DB every 20 lines
|
||||
if (outputLines.length % 20 === 0) {
|
||||
db.prepare('UPDATE jobs SET output = ? WHERE id = ?').run(outputLines.join('\n'), job.id);
|
||||
emitJobUpdate(job.id, 'running', outputLines.join('\n'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Local execution — spawn ffmpeg directly
|
||||
const proc = Bun.spawn(['sh', '-c', job.command], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
const readStream = async (readable: ReadableStream<Uint8Array>, prefix = '') => {
|
||||
const reader = readable.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const text = decoder.decode(value);
|
||||
const lines = text.split('\n').filter((l) => l.trim());
|
||||
for (const line of lines) {
|
||||
outputLines.push(prefix + line);
|
||||
}
|
||||
if (outputLines.length % 20 === 0) {
|
||||
db.prepare('UPDATE jobs SET output = ? WHERE id = ?').run(outputLines.join('\n'), job.id);
|
||||
emitJobUpdate(job.id, 'running', outputLines.join('\n'));
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
readStream(proc.stdout),
|
||||
readStream(proc.stderr, '[stderr] '),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`FFmpeg exited with code ${exitCode}`);
|
||||
}
|
||||
}
|
||||
|
||||
const fullOutput = outputLines.join('\n');
|
||||
db.prepare(
|
||||
"UPDATE jobs SET status = 'done', exit_code = 0, output = ?, completed_at = datetime('now') WHERE id = ?"
|
||||
).run(fullOutput, job.id);
|
||||
emitJobUpdate(job.id, 'done', fullOutput);
|
||||
|
||||
// Mark plan as done
|
||||
db.prepare(
|
||||
"UPDATE review_plans SET status = 'done' WHERE item_id = ?"
|
||||
).run(job.item_id);
|
||||
} catch (err) {
|
||||
const fullOutput = outputLines.join('\n') + '\n' + String(err);
|
||||
db.prepare(
|
||||
"UPDATE jobs SET status = 'error', exit_code = 1, output = ?, completed_at = datetime('now') WHERE id = ?"
|
||||
).run(fullOutput, job.id);
|
||||
emitJobUpdate(job.id, 'error', fullOutput);
|
||||
|
||||
db.prepare(
|
||||
"UPDATE review_plans SET status = 'error' WHERE item_id = ?"
|
||||
).run(job.item_id);
|
||||
}
|
||||
}
|
||||
|
||||
export default app;
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Hono } from 'hono';
|
||||
import { getDb } from '../db/index';
|
||||
import { testConnection } from '../services/ssh';
|
||||
import type { Node } from '../types';
|
||||
import { NodesPage, NodesList, NodeStatusBadge } from '../views/nodes';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get('/', (c) => {
|
||||
const db = getDb();
|
||||
const nodes = db.prepare('SELECT * FROM nodes ORDER BY name').all() as Node[];
|
||||
return c.html(<NodesPage nodes={nodes} />);
|
||||
});
|
||||
|
||||
app.post('/', async (c) => {
|
||||
const db = getDb();
|
||||
const body = await c.req.formData();
|
||||
|
||||
const name = body.get('name') as string;
|
||||
const host = body.get('host') as string;
|
||||
const port = Number(body.get('port') ?? '22');
|
||||
const username = body.get('username') as string;
|
||||
const ffmpegPath = (body.get('ffmpeg_path') as string) || 'ffmpeg';
|
||||
const workDir = (body.get('work_dir') as string) || '/tmp';
|
||||
const keyFile = body.get('private_key') as File | null;
|
||||
|
||||
if (!name || !host || !username || !keyFile) {
|
||||
return c.html(<div class="alert alert-error">All fields are required.</div>);
|
||||
}
|
||||
|
||||
const privateKey = await keyFile.text();
|
||||
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO nodes (name, host, port, username, private_key, ffmpeg_path, work_dir)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(name, host, port, username, privateKey, ffmpegPath, workDir);
|
||||
} catch (e) {
|
||||
if (String(e).includes('UNIQUE')) {
|
||||
return c.html(<div class="alert alert-error">A node named "{name}" already exists.</div>);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const nodes = db.prepare('SELECT * FROM nodes ORDER BY name').all() as Node[];
|
||||
return c.html(<NodesList nodes={nodes} />);
|
||||
});
|
||||
|
||||
app.post('/:id/delete', (c) => {
|
||||
const db = getDb();
|
||||
const id = Number(c.req.param('id'));
|
||||
db.prepare('DELETE FROM nodes WHERE id = ?').run(id);
|
||||
return c.redirect('/nodes');
|
||||
});
|
||||
|
||||
app.post('/:id/test', async (c) => {
|
||||
const db = getDb();
|
||||
const id = Number(c.req.param('id'));
|
||||
const node = db.prepare('SELECT * FROM nodes WHERE id = ?').get(id) as Node | undefined;
|
||||
|
||||
if (!node) return c.notFound();
|
||||
|
||||
const result = await testConnection(node);
|
||||
const status = result.ok ? 'ok' : 'error';
|
||||
|
||||
db.prepare(
|
||||
"UPDATE nodes SET status = ?, last_checked_at = datetime('now') WHERE id = ?"
|
||||
).run(status, id);
|
||||
|
||||
return c.html(<NodeStatusBadge status={result.ok ? 'ok' : `error: ${result.error}`} />);
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -1,305 +0,0 @@
|
||||
import { Hono } from 'hono';
|
||||
import { getDb, getConfig } from '../db/index';
|
||||
import { analyzeItem } from '../services/analyzer';
|
||||
import { buildCommand } from '../services/ffmpeg';
|
||||
import { normalizeLanguage } from '../services/jellyfin';
|
||||
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '../types';
|
||||
import {
|
||||
ReviewListPage,
|
||||
ReviewDetailPage,
|
||||
ReviewDetailFragment,
|
||||
} from '../views/review';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function getSubtitleLanguages(): string[] {
|
||||
return JSON.parse(getConfig('subtitle_languages') ?? '["eng","deu","spa"]');
|
||||
}
|
||||
|
||||
function computeCommand(
|
||||
item: MediaItem,
|
||||
streams: MediaStream[],
|
||||
decisions: StreamDecision[]
|
||||
): string | null {
|
||||
if (decisions.every((d) => d.action === 'keep')) return null;
|
||||
return buildCommand(item, streams, decisions);
|
||||
}
|
||||
|
||||
function countsByFilter(db: ReturnType<typeof getDb>): Record<string, number> {
|
||||
const total = (db.prepare('SELECT COUNT(*) as n FROM review_plans').get() as { n: number }).n;
|
||||
const noops = (db.prepare('SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1').get() as { n: number }).n;
|
||||
const pending = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }).n;
|
||||
const approved = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").get() as { n: number }).n;
|
||||
const skipped = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'skipped'").get() as { n: number }).n;
|
||||
const done = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'done'").get() as { n: number }).n;
|
||||
const error = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'error'").get() as { n: number }).n;
|
||||
const manual = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE needs_review = 1 AND original_language IS NULL").get() as { n: number }).n;
|
||||
|
||||
return { all: total, needs_action: pending, noop: noops, approved, skipped, done, error, manual };
|
||||
}
|
||||
|
||||
// ─── List view ────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/', (c) => {
|
||||
const db = getDb();
|
||||
const filter = c.req.query('filter') ?? 'all';
|
||||
|
||||
let whereClause = '1=1';
|
||||
switch (filter) {
|
||||
case 'needs_action': whereClause = "rp.status = 'pending' AND rp.is_noop = 0"; break;
|
||||
case 'noop': whereClause = 'rp.is_noop = 1'; break;
|
||||
case 'manual': whereClause = 'mi.needs_review = 1 AND mi.original_language IS NULL'; break;
|
||||
case 'approved': whereClause = "rp.status = 'approved'"; break;
|
||||
case 'skipped': whereClause = "rp.status = 'skipped'"; break;
|
||||
case 'done': whereClause = "rp.status = 'done'"; break;
|
||||
case 'error': whereClause = "rp.status = 'error'"; break;
|
||||
}
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
mi.*,
|
||||
rp.id as plan_id,
|
||||
rp.status as plan_status,
|
||||
rp.is_noop,
|
||||
rp.notes as plan_notes,
|
||||
rp.reviewed_at,
|
||||
rp.created_at as plan_created_at,
|
||||
COUNT(CASE WHEN sd.action = 'remove' THEN 1 END) as remove_count,
|
||||
COUNT(CASE WHEN sd.action = 'keep' THEN 1 END) as keep_count
|
||||
FROM media_items mi
|
||||
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
||||
LEFT JOIN stream_decisions sd ON sd.plan_id = rp.id
|
||||
WHERE ${whereClause}
|
||||
GROUP BY mi.id
|
||||
ORDER BY mi.series_name NULLS LAST, mi.name, mi.season_number, mi.episode_number
|
||||
LIMIT 500
|
||||
`).all() as (MediaItem & {
|
||||
plan_id: number | null;
|
||||
plan_status: string | null;
|
||||
is_noop: number | null;
|
||||
plan_notes: string | null;
|
||||
reviewed_at: string | null;
|
||||
plan_created_at: string | null;
|
||||
remove_count: number;
|
||||
keep_count: number;
|
||||
})[];
|
||||
|
||||
const items = rows.map((r) => ({
|
||||
item: r as unknown as MediaItem,
|
||||
plan: r.plan_id != null ? {
|
||||
id: r.plan_id,
|
||||
item_id: r.id,
|
||||
status: r.plan_status ?? 'pending',
|
||||
is_noop: r.is_noop ?? 0,
|
||||
notes: r.plan_notes,
|
||||
reviewed_at: r.reviewed_at,
|
||||
created_at: r.plan_created_at ?? '',
|
||||
} as ReviewPlan : null,
|
||||
removeCount: r.remove_count,
|
||||
keepCount: r.keep_count,
|
||||
}));
|
||||
|
||||
const totalCounts = countsByFilter(db);
|
||||
|
||||
return c.html(<ReviewListPage items={items} filter={filter} totalCounts={totalCounts} />);
|
||||
});
|
||||
|
||||
// ─── Detail view ──────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/:id', (c) => {
|
||||
const db = getDb();
|
||||
const id = Number(c.req.param('id'));
|
||||
const { item, streams, plan, decisions, command } = loadItemDetail(db, id);
|
||||
|
||||
if (!item) return c.notFound();
|
||||
|
||||
// Inline HTMX expansion vs full page
|
||||
const isHtmx = c.req.header('HX-Request') === 'true';
|
||||
if (isHtmx) {
|
||||
return c.html(
|
||||
<ReviewDetailFragment item={item} streams={streams} plan={plan} decisions={decisions} command={command} />
|
||||
);
|
||||
}
|
||||
|
||||
return c.html(
|
||||
<ReviewDetailPage item={item} streams={streams} plan={plan} decisions={decisions} command={command} />
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Override original language ───────────────────────────────────────────────
|
||||
|
||||
app.patch('/:id/language', async (c) => {
|
||||
const db = getDb();
|
||||
const id = Number(c.req.param('id'));
|
||||
const body = await c.req.formData();
|
||||
const lang = (body.get('language') as string) || null;
|
||||
|
||||
db.prepare(
|
||||
"UPDATE media_items SET original_language = ?, orig_lang_source = 'manual', needs_review = 0 WHERE id = ?"
|
||||
).run(lang ? normalizeLanguage(lang) : null, id);
|
||||
|
||||
// Re-analyze with new language
|
||||
reanalyze(db, id);
|
||||
|
||||
const { item, streams, plan, decisions, command } = loadItemDetail(db, id);
|
||||
if (!item) return c.notFound();
|
||||
|
||||
return c.html(
|
||||
<ReviewDetailFragment item={item} streams={streams} plan={plan} decisions={decisions} command={command} />
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Toggle stream action ─────────────────────────────────────────────────────
|
||||
|
||||
app.patch('/:id/stream/:streamId', async (c) => {
|
||||
const db = getDb();
|
||||
const itemId = Number(c.req.param('id'));
|
||||
const streamId = Number(c.req.param('streamId'));
|
||||
const body = await c.req.formData();
|
||||
const action = body.get('action') as 'keep' | 'remove';
|
||||
|
||||
// Get plan
|
||||
const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number } | undefined;
|
||||
if (!plan) return c.notFound();
|
||||
|
||||
db.prepare(
|
||||
'UPDATE stream_decisions SET action = ? WHERE plan_id = ? AND stream_id = ?'
|
||||
).run(action, plan.id, streamId);
|
||||
|
||||
// Recompute is_noop
|
||||
const allKeep = (db.prepare(
|
||||
"SELECT COUNT(*) as n FROM stream_decisions WHERE plan_id = ? AND action = 'remove'"
|
||||
).get(plan.id) as { n: number }).n === 0;
|
||||
db.prepare('UPDATE review_plans SET is_noop = ? WHERE id = ?').run(allKeep ? 1 : 0, plan.id);
|
||||
|
||||
const { item, streams, decisions, command } = loadItemDetail(db, itemId);
|
||||
if (!item) return c.notFound();
|
||||
const planFull = db.prepare('SELECT * FROM review_plans WHERE id = ?').get(plan.id) as ReviewPlan;
|
||||
|
||||
return c.html(
|
||||
<ReviewDetailFragment item={item} streams={streams} plan={planFull} decisions={decisions} command={command} />
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Approve ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/:id/approve', (c) => {
|
||||
const db = getDb();
|
||||
const id = Number(c.req.param('id'));
|
||||
|
||||
const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(id) as ReviewPlan | undefined;
|
||||
if (!plan) return c.notFound();
|
||||
|
||||
db.prepare(
|
||||
"UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?"
|
||||
).run(plan.id);
|
||||
|
||||
// Create job
|
||||
if (!plan.is_noop) {
|
||||
const { item, streams, decisions } = loadItemDetail(db, id);
|
||||
if (item) {
|
||||
const command = buildCommand(item, streams, decisions);
|
||||
db.prepare(
|
||||
"INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')"
|
||||
).run(id, command);
|
||||
}
|
||||
}
|
||||
|
||||
const isHtmx = c.req.header('HX-Request') === 'true';
|
||||
return isHtmx ? c.redirect('/review', 303) : c.redirect('/review');
|
||||
});
|
||||
|
||||
// ─── Skip ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/:id/skip', (c) => {
|
||||
const db = getDb();
|
||||
const id = Number(c.req.param('id'));
|
||||
|
||||
db.prepare(
|
||||
"UPDATE review_plans SET status = 'skipped', reviewed_at = datetime('now') WHERE item_id = ?"
|
||||
).run(id);
|
||||
|
||||
return c.redirect('/review');
|
||||
});
|
||||
|
||||
// ─── Approve all ──────────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/approve-all', (c) => {
|
||||
const db = getDb();
|
||||
|
||||
const pending = db.prepare(
|
||||
"SELECT rp.*, mi.id as item_id FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE rp.status = 'pending' AND rp.is_noop = 0"
|
||||
).all() as (ReviewPlan & { item_id: number })[];
|
||||
|
||||
for (const plan of pending) {
|
||||
db.prepare(
|
||||
"UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?"
|
||||
).run(plan.id);
|
||||
|
||||
const { item, streams, decisions } = loadItemDetail(db, plan.item_id);
|
||||
if (item) {
|
||||
const command = buildCommand(item, streams, decisions);
|
||||
db.prepare(
|
||||
"INSERT INTO jobs (item_id, command, status) VALUES (?, ?, 'pending')"
|
||||
).run(plan.item_id, command);
|
||||
}
|
||||
}
|
||||
|
||||
return c.redirect('/review');
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function loadItemDetail(db: ReturnType<typeof getDb>, itemId: number) {
|
||||
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(itemId) as MediaItem | undefined;
|
||||
if (!item) return { item: null, streams: [], plan: null, decisions: [], command: null };
|
||||
|
||||
const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(itemId) as MediaStream[];
|
||||
const plan = db.prepare('SELECT * FROM review_plans WHERE item_id = ?').get(itemId) as ReviewPlan | undefined | null;
|
||||
const decisions = plan
|
||||
? db.prepare('SELECT * FROM stream_decisions WHERE plan_id = ?').all(plan.id) as StreamDecision[]
|
||||
: [];
|
||||
|
||||
const command = plan && !plan.is_noop && decisions.some((d) => d.action === 'remove')
|
||||
? buildCommand(item, streams, decisions)
|
||||
: null;
|
||||
|
||||
return { item, streams, plan: plan ?? null, decisions, command };
|
||||
}
|
||||
|
||||
function reanalyze(db: ReturnType<typeof getDb>, itemId: number): void {
|
||||
const item = db.prepare('SELECT * FROM media_items WHERE id = ?').get(itemId) as MediaItem;
|
||||
if (!item) return;
|
||||
|
||||
const streams = db.prepare('SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index').all(itemId) as MediaStream[];
|
||||
const subtitleLanguages = getSubtitleLanguages();
|
||||
const analysis = analyzeItem(
|
||||
{ original_language: item.original_language, needs_review: item.needs_review },
|
||||
streams,
|
||||
{ subtitleLanguages }
|
||||
);
|
||||
|
||||
// Upsert plan
|
||||
db.prepare(`
|
||||
INSERT INTO review_plans (item_id, status, is_noop, notes)
|
||||
VALUES (?, 'pending', ?, ?)
|
||||
ON CONFLICT(item_id) DO UPDATE SET
|
||||
status = 'pending',
|
||||
is_noop = excluded.is_noop,
|
||||
notes = excluded.notes
|
||||
`).run(itemId, analysis.is_noop ? 1 : 0, analysis.notes);
|
||||
|
||||
const plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(itemId) as { id: number };
|
||||
|
||||
// Replace decisions
|
||||
db.prepare('DELETE FROM stream_decisions WHERE plan_id = ?').run(plan.id);
|
||||
for (const dec of analysis.decisions) {
|
||||
db.prepare(
|
||||
'INSERT INTO stream_decisions (plan_id, stream_id, action, target_index) VALUES (?, ?, ?, ?)'
|
||||
).run(plan.id, dec.stream_id, dec.action, dec.target_index);
|
||||
}
|
||||
}
|
||||
|
||||
export default app;
|
||||
@@ -1,103 +0,0 @@
|
||||
import { Hono } from 'hono';
|
||||
import { getConfig, setConfig, getAllConfig } from '../db/index';
|
||||
import { testConnection as testJellyfin, getUsers } from '../services/jellyfin';
|
||||
import { testConnection as testRadarr } from '../services/radarr';
|
||||
import { testConnection as testSonarr } from '../services/sonarr';
|
||||
import { SetupPage, ConnStatusFragment } from '../views/setup';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get('/', async (c) => {
|
||||
const setupComplete = getConfig('setup_complete') === '1';
|
||||
if (setupComplete) return c.redirect('/');
|
||||
|
||||
const step = Number(c.req.query('step') ?? '1') as 1 | 2 | 3 | 4;
|
||||
const config = getAllConfig();
|
||||
return c.html(<SetupPage step={step} config={config} />);
|
||||
});
|
||||
|
||||
app.post('/jellyfin', async (c) => {
|
||||
const body = await c.req.formData();
|
||||
const url = (body.get('url') as string)?.replace(/\/$/, '');
|
||||
const apiKey = body.get('api_key') as string;
|
||||
|
||||
if (!url || !apiKey) {
|
||||
return c.html(<ConnStatusFragment ok={false} error="URL and API key are required" />);
|
||||
}
|
||||
|
||||
const result = await testJellyfin({ url, apiKey, userId: '' });
|
||||
if (!result.ok) {
|
||||
return c.html(<ConnStatusFragment ok={false} error={result.error} />);
|
||||
}
|
||||
|
||||
// Auto-discover user ID
|
||||
let userId = '';
|
||||
try {
|
||||
const users = await getUsers({ url, apiKey });
|
||||
const admin = users.find((u) => u.Name === 'admin') ?? users[0];
|
||||
userId = admin?.Id ?? '';
|
||||
} catch {
|
||||
// Non-fatal; user can enter manually later
|
||||
}
|
||||
|
||||
setConfig('jellyfin_url', url);
|
||||
setConfig('jellyfin_api_key', apiKey);
|
||||
if (userId) setConfig('jellyfin_user_id', userId);
|
||||
|
||||
return c.html(<ConnStatusFragment ok={true} nextUrl="/setup?step=2" />);
|
||||
});
|
||||
|
||||
app.post('/radarr', async (c) => {
|
||||
const body = await c.req.formData();
|
||||
const url = (body.get('url') as string)?.replace(/\/$/, '');
|
||||
const apiKey = body.get('api_key') as string;
|
||||
|
||||
if (!url || !apiKey) {
|
||||
// Skip was clicked with empty fields — go to next step
|
||||
return c.redirect('/setup?step=3');
|
||||
}
|
||||
|
||||
const result = await testRadarr({ url, apiKey });
|
||||
if (!result.ok) {
|
||||
return c.html(<ConnStatusFragment ok={false} error={result.error} />);
|
||||
}
|
||||
|
||||
setConfig('radarr_url', url);
|
||||
setConfig('radarr_api_key', apiKey);
|
||||
setConfig('radarr_enabled', '1');
|
||||
|
||||
return c.html(<ConnStatusFragment ok={true} nextUrl="/setup?step=3" />);
|
||||
});
|
||||
|
||||
app.post('/sonarr', async (c) => {
|
||||
const body = await c.req.formData();
|
||||
const url = (body.get('url') as string)?.replace(/\/$/, '');
|
||||
const apiKey = body.get('api_key') as string;
|
||||
|
||||
if (!url || !apiKey) {
|
||||
return c.redirect('/setup?step=4');
|
||||
}
|
||||
|
||||
const result = await testSonarr({ url, apiKey });
|
||||
if (!result.ok) {
|
||||
return c.html(<ConnStatusFragment ok={false} error={result.error} />);
|
||||
}
|
||||
|
||||
setConfig('sonarr_url', url);
|
||||
setConfig('sonarr_api_key', apiKey);
|
||||
setConfig('sonarr_enabled', '1');
|
||||
|
||||
return c.html(<ConnStatusFragment ok={true} nextUrl="/setup?step=4" />);
|
||||
});
|
||||
|
||||
app.post('/complete', async (c) => {
|
||||
const body = await c.req.formData();
|
||||
const langs = body.getAll('subtitle_lang') as string[];
|
||||
if (langs.length > 0) {
|
||||
setConfig('subtitle_languages', JSON.stringify(langs));
|
||||
}
|
||||
setConfig('setup_complete', '1');
|
||||
return c.redirect('/');
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { join } from 'node:path';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { SCHEMA, DEFAULT_CONFIG } from './schema';
|
||||
|
||||
const dataDir = process.env.DATA_DIR ?? './data';
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
|
||||
const dbPath = join(dataDir, 'netfelix.db');
|
||||
|
||||
let _db: Database | null = null;
|
||||
|
||||
export function getDb(): Database {
|
||||
if (_db) return _db;
|
||||
_db = new Database(dbPath, { create: true });
|
||||
_db.exec(SCHEMA);
|
||||
seedDefaults(_db);
|
||||
return _db;
|
||||
}
|
||||
|
||||
function seedDefaults(db: Database): void {
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)'
|
||||
);
|
||||
for (const [key, value] of Object.entries(DEFAULT_CONFIG)) {
|
||||
insert.run(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfig(key: string): string | null {
|
||||
const row = getDb()
|
||||
.prepare('SELECT value FROM config WHERE key = ?')
|
||||
.get(key) as { value: string } | undefined;
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export function setConfig(key: string, value: string): void {
|
||||
getDb()
|
||||
.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)')
|
||||
.run(key, value);
|
||||
}
|
||||
|
||||
export function getAllConfig(): Record<string, string> {
|
||||
const rows = getDb()
|
||||
.prepare('SELECT key, value FROM config')
|
||||
.all() as { key: string; value: string }[];
|
||||
return Object.fromEntries(rows.map((r) => [r.key, r.value ?? '']));
|
||||
}
|
||||
91
src/features/dashboard/DashboardPage.tsx
Normal file
91
src/features/dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from '@tanstack/react-router';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
import { Alert } from '~/shared/components/ui/alert';
|
||||
|
||||
interface Stats {
|
||||
totalItems: number; scanned: number; needsAction: number;
|
||||
approved: number; done: number; errors: number; noChange: number;
|
||||
}
|
||||
|
||||
interface DashboardData { stats: Stats; scanRunning: boolean; setupComplete: boolean; }
|
||||
|
||||
function StatCard({ label, value, danger }: { label: string; value: number; danger?: boolean }) {
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg px-3.5 py-2.5">
|
||||
<div className={`text-[1.6rem] font-bold leading-[1.1] ${danger ? 'text-red-600' : ''}`}>
|
||||
{value.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-[0.7rem] text-gray-500 mt-0.5">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [starting, setStarting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<DashboardData>('/api/dashboard').then((d) => {
|
||||
setData(d);
|
||||
setLoading(false);
|
||||
if (!d.setupComplete) navigate({ to: '/setup' });
|
||||
}).catch(() => setLoading(false));
|
||||
}, [navigate]);
|
||||
|
||||
const startScan = async () => {
|
||||
setStarting(true);
|
||||
await api.post('/api/scan/start', {}).catch(() => {});
|
||||
navigate({ to: '/scan' });
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
||||
if (!data) return <Alert variant="error">Failed to load dashboard.</Alert>;
|
||||
|
||||
const { stats, scanRunning } = data;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<h1 className="text-xl font-bold m-0">Dashboard</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-2.5 mb-5">
|
||||
<StatCard label="Total items" value={stats.totalItems} />
|
||||
<StatCard label="Scanned" value={stats.scanned} />
|
||||
<StatCard label="Needs action" value={stats.needsAction} />
|
||||
<StatCard label="No change needed" value={stats.noChange} />
|
||||
<StatCard label="Approved / queued" value={stats.approved} />
|
||||
<StatCard label="Done" value={stats.done} />
|
||||
{stats.errors > 0 && <StatCard label="Errors" value={stats.errors} danger />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
{scanRunning ? (
|
||||
<Link to="/scan" className="inline-flex items-center justify-center gap-1 rounded px-3 py-1.5 text-sm font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline">
|
||||
⏳ Scan running…
|
||||
</Link>
|
||||
) : (
|
||||
<Button onClick={startScan} disabled={starting}>
|
||||
{starting ? 'Starting…' : '▶ Start Scan'}
|
||||
</Button>
|
||||
)}
|
||||
<Link to="/review" className="inline-flex items-center justify-center gap-1 rounded px-3 py-1.5 text-sm font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline">
|
||||
Review changes
|
||||
</Link>
|
||||
<Link to="/execute" className="inline-flex items-center justify-center gap-1 rounded px-3 py-1.5 text-sm font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline">
|
||||
Execute jobs
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{stats.scanned === 0 && (
|
||||
<Alert variant="info">
|
||||
Library not scanned yet. Click <strong>Start Scan</strong> to begin.
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
src/features/execute/ExecutePage.tsx
Normal file
183
src/features/execute/ExecutePage.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
import { Select } from '~/shared/components/ui/select';
|
||||
import type { Job, Node, MediaItem } from '~/shared/lib/types';
|
||||
|
||||
interface JobEntry { job: Job; item: MediaItem | null; node: Node | null; }
|
||||
interface ExecuteData { jobs: JobEntry[]; nodes: Node[]; }
|
||||
|
||||
function itemName(job: Job, item: MediaItem | null): string {
|
||||
if (!item) return `Item #${job.item_id}`;
|
||||
if (item.type === 'Episode' && item.series_name) {
|
||||
return `${item.series_name} S${String(item.season_number ?? 0).padStart(2, '0')}E${String(item.episode_number ?? 0).padStart(2, '0')}`;
|
||||
}
|
||||
return item.name;
|
||||
}
|
||||
|
||||
export function ExecutePage() {
|
||||
const [data, setData] = useState<ExecuteData | null>(null);
|
||||
const [logs, setLogs] = useState<Map<number, string>>(new Map());
|
||||
const [logVisible, setLogVisible] = useState<Set<number>>(new Set());
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
|
||||
const load = () => api.get<ExecuteData>('/api/execute').then(setData);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
// SSE for live job updates
|
||||
useEffect(() => {
|
||||
const es = new EventSource('/api/execute/events');
|
||||
esRef.current = es;
|
||||
es.addEventListener('job_update', (e) => {
|
||||
const d = JSON.parse(e.data) as { id: number; status: string; output?: string };
|
||||
setData((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
jobs: prev.jobs.map((j) =>
|
||||
j.job.id === d.id ? { ...j, job: { ...j.job, status: d.status as Job['status'] } } : j
|
||||
),
|
||||
};
|
||||
});
|
||||
if (d.output !== undefined) {
|
||||
setLogs((prev) => { const m = new Map(prev); m.set(d.id, d.output!); return m; });
|
||||
}
|
||||
// Reload row on terminal state to get accurate data
|
||||
if (d.status === 'done' || d.status === 'error') {
|
||||
api.get<{ job: Job; item: MediaItem | null; node: Node | null; nodes: Node[] }>(`/api/execute/job/${d.id}/run`).catch(() => {});
|
||||
}
|
||||
});
|
||||
return () => es.close();
|
||||
}, []);
|
||||
|
||||
const startAll = async () => { await api.post('/api/execute/start'); load(); };
|
||||
const runJob = async (id: number) => { await api.post(`/api/execute/job/${id}/run`); load(); };
|
||||
const cancelJob = async (id: number) => { await api.post(`/api/execute/job/${id}/cancel`); load(); };
|
||||
const assignNode = async (jobId: number, nodeId: number | null) => {
|
||||
await api.post(`/api/execute/job/${jobId}/assign`, { node_id: nodeId });
|
||||
load();
|
||||
};
|
||||
|
||||
const toggleLog = (id: number) => setLogVisible((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
|
||||
|
||||
if (!data) return <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
||||
|
||||
const { jobs, nodes } = data;
|
||||
const pending = jobs.filter((j) => j.job.status === 'pending').length;
|
||||
const running = jobs.filter((j) => j.job.status === 'running').length;
|
||||
const done = jobs.filter((j) => j.job.status === 'done').length;
|
||||
const errors = jobs.filter((j) => j.job.status === 'error').length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<h1 className="text-xl font-bold m-0">Execute Jobs</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6 flex-wrap">
|
||||
<Badge variant="pending">{pending} pending</Badge>
|
||||
{running > 0 && <Badge variant="running">{running} running</Badge>}
|
||||
{done > 0 && <Badge variant="done">{done} done</Badge>}
|
||||
{errors > 0 && <Badge variant="error">{errors} error(s)</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mb-6">
|
||||
{pending > 0 && <Button onClick={startAll}>▶ Run all pending</Button>}
|
||||
{jobs.length === 0 && (
|
||||
<p className="text-gray-500 m-0">
|
||||
No jobs yet. Go to <Link to="/review">Review</Link> and approve items first.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{jobs.length > 0 && (
|
||||
<table className="w-full border-collapse text-[0.82rem]">
|
||||
<thead>
|
||||
<tr>
|
||||
{['#', 'Item', 'Command', 'Node', 'Status', 'Actions'].map((h) => (
|
||||
<th key={h} className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map(({ job, item, node }) => {
|
||||
const name = itemName(job, item);
|
||||
const cmdShort = job.command.length > 80 ? job.command.slice(0, 77) + '…' : job.command;
|
||||
const jobLog = logs.get(job.id) ?? job.output ?? '';
|
||||
const showLog = logVisible.has(job.id) || job.status === 'running' || job.status === 'error';
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr key={job.id} className="hover:bg-gray-50">
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{job.id}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
<div className="truncate max-w-[200px]" title={name}>{name}</div>
|
||||
{item && (
|
||||
<div className="text-[0.72rem] mt-0.5">
|
||||
<Link to="/review/$id" params={{ id: String(item.id) }} className="text-gray-400 no-underline hover:underline">← Details</Link>
|
||||
</div>
|
||||
)}
|
||||
{item?.file_path && (
|
||||
<div className="font-mono text-gray-400 text-[0.72rem] truncate max-w-[200px] mt-0.5" title={item.file_path}>
|
||||
{item.file_path.split('/').pop()}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-[0.75rem] max-w-[300px]">
|
||||
<span title={job.command}>{cmdShort}</span>
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
{job.status === 'pending' ? (
|
||||
<Select
|
||||
value={node?.id ?? ''}
|
||||
onChange={(e) => assignNode(job.id, e.target.value ? Number(e.target.value) : null)}
|
||||
className="text-[0.8rem] py-0.5 px-1 w-auto"
|
||||
>
|
||||
<option value="">Local</option>
|
||||
{nodes.map((n) => <option key={n.id} value={n.id}>{n.name} ({n.host})</option>)}
|
||||
</Select>
|
||||
) : (
|
||||
<span className="text-gray-500">{node?.name ?? 'Local'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
<Badge variant={job.status}>{job.status}</Badge>
|
||||
{job.exit_code != null && job.exit_code !== 0 && <Badge variant="error" className="ml-1">exit {job.exit_code}</Badge>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap">
|
||||
<div className="flex gap-1 items-center">
|
||||
{job.status === 'pending' && (
|
||||
<>
|
||||
<Button size="sm" onClick={() => runJob(job.id)}>▶ Run</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => cancelJob(job.id)}>✕</Button>
|
||||
</>
|
||||
)}
|
||||
{(job.status === 'done' || job.status === 'error') && jobLog && (
|
||||
<Button size="sm" variant="secondary" onClick={() => toggleLog(job.id)}>Log</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{jobLog && (
|
||||
<tr key={`log-${job.id}`}>
|
||||
<td colSpan={6} className="p-0 border-b border-gray-100">
|
||||
{showLog && (
|
||||
<div className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#d4d4d4] px-3.5 py-2.5 rounded max-h-[260px] overflow-y-auto whitespace-pre-wrap break-all">
|
||||
{jobLog}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
src/features/nodes/NodesPage.tsx
Normal file
146
src/features/nodes/NodesPage.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
import { Input } from '~/shared/components/ui/input';
|
||||
import { Alert } from '~/shared/components/ui/alert';
|
||||
import type { Node } from '~/shared/lib/types';
|
||||
|
||||
interface NodesData { nodes: Node[]; }
|
||||
|
||||
function nodeStatusVariant(status: string): 'done' | 'error' | 'pending' {
|
||||
if (status === 'ok') return 'done';
|
||||
if (status.startsWith('error')) return 'error';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export function NodesPage() {
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
const [testing, setTesting] = useState<Set<number>>(new Set());
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = () => api.get<NodesData>('/api/nodes').then((d) => setNodes(d.nodes));
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const submit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
const form = e.currentTarget;
|
||||
const fd = new FormData(form);
|
||||
const result = await api.postForm<NodesData>('/api/nodes', fd).catch((err) => { setError(String(err)); return null; });
|
||||
if (result) { setNodes(result.nodes); form.reset(); if (fileRef.current) fileRef.current.value = ''; }
|
||||
};
|
||||
|
||||
const deleteNode = async (id: number) => {
|
||||
if (!confirm('Remove node?')) return;
|
||||
await api.post(`/api/nodes/${id}/delete`);
|
||||
load();
|
||||
};
|
||||
|
||||
const testNode = async (id: number) => {
|
||||
setTesting((s) => { const n = new Set(s); n.add(id); return n; });
|
||||
await api.post<{ ok: boolean; status: string }>(`/api/nodes/${id}/test`);
|
||||
setTesting((s) => { const n = new Set(s); n.delete(id); return n; });
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<h1 className="text-xl font-bold m-0">Remote Nodes</h1>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-500 mb-4">
|
||||
Remote nodes run FFmpeg over SSH on shared storage. The path to the media file must be
|
||||
identical on both this server and the remote node.
|
||||
</p>
|
||||
|
||||
{/* Add form */}
|
||||
<div className="border border-gray-200 rounded-lg p-4 mb-4">
|
||||
<div className="font-semibold text-sm mb-3">Add Node</div>
|
||||
{error && <Alert variant="error" className="mb-3">{error}</Alert>}
|
||||
<form onSubmit={submit}>
|
||||
<div className="grid grid-cols-2 gap-4 mb-3">
|
||||
<label className="block text-sm text-gray-700 mb-0.5">
|
||||
Name
|
||||
<Input name="name" placeholder="my-server" required className="mt-0.5" />
|
||||
</label>
|
||||
<label className="block text-sm text-gray-700 mb-0.5">
|
||||
Host
|
||||
<Input name="host" placeholder="192.168.1.200" required className="mt-0.5" />
|
||||
</label>
|
||||
<label className="block text-sm text-gray-700 mb-0.5">
|
||||
SSH Port
|
||||
<Input type="number" name="port" defaultValue="22" min="1" max="65535" className="mt-0.5" />
|
||||
</label>
|
||||
<label className="block text-sm text-gray-700 mb-0.5">
|
||||
Username
|
||||
<Input name="username" placeholder="root" required className="mt-0.5" />
|
||||
</label>
|
||||
<label className="block text-sm text-gray-700 mb-0.5">
|
||||
FFmpeg path
|
||||
<Input name="ffmpeg_path" defaultValue="ffmpeg" className="mt-0.5" />
|
||||
</label>
|
||||
<label className="block text-sm text-gray-700 mb-0.5">
|
||||
Work directory
|
||||
<Input name="work_dir" defaultValue="/tmp" className="mt-0.5" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="block text-sm text-gray-700 mb-0.5">
|
||||
Private key (PEM)
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
name="private_key"
|
||||
accept=".pem,.key,text/plain"
|
||||
required
|
||||
className="border border-gray-300 rounded px-2.5 py-1.5 text-sm bg-white w-full mt-0.5"
|
||||
/>
|
||||
<small className="text-xs text-gray-500 mt-0.5 block">Upload your SSH private key file. Stored securely in the database.</small>
|
||||
</label>
|
||||
<Button type="submit" className="mt-3">Add Node</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Node list */}
|
||||
{nodes.length === 0 ? (
|
||||
<p className="text-gray-500">No nodes configured. Add one above.</p>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-[0.82rem]">
|
||||
<thead>
|
||||
<tr>
|
||||
{['Name', 'Host', 'Port', 'User', 'FFmpeg', 'Status', 'Actions'].map((h) => (
|
||||
<th key={h} className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodes.map((node) => (
|
||||
<tr key={node.id} className="hover:bg-gray-50">
|
||||
<td className="py-1.5 px-2 border-b border-gray-100"><strong>{node.name}</strong></td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{node.host}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{node.port}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{node.username}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{node.ffmpeg_path}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
<Badge variant={nodeStatusVariant(node.status)}>{node.status}</Badge>
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
<div className="flex gap-1 items-center">
|
||||
<Button size="sm" onClick={() => testNode(node.id)} disabled={testing.has(node.id)}>
|
||||
{testing.has(node.id) ? '…' : 'Test'}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => deleteNode(node.id)}>Remove</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import type React from 'react';
|
||||
334
src/features/review/AudioDetailPage.tsx
Normal file
334
src/features/review/AudioDetailPage.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useParams } from '@tanstack/react-router';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
import { Alert } from '~/shared/components/ui/alert';
|
||||
import { Select } from '~/shared/components/ui/select';
|
||||
import { langName, LANG_NAMES } from '~/shared/lib/lang';
|
||||
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '~/shared/lib/types';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DetailData {
|
||||
item: MediaItem; streams: MediaStream[];
|
||||
plan: ReviewPlan | null; decisions: StreamDecision[];
|
||||
command: string | null; dockerCommand: string | null; dockerMountDir: string | null;
|
||||
}
|
||||
|
||||
// ─── Utilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function effectiveLabel(s: MediaStream, dec: StreamDecision | undefined): string {
|
||||
if (dec?.custom_title) return dec.custom_title;
|
||||
if (s.type === 'Subtitle') {
|
||||
if (!s.language) return '';
|
||||
const base = langName(s.language);
|
||||
if (s.is_forced) return `${base} (Forced)`;
|
||||
if (s.is_hearing_impaired) return `${base} (CC)`;
|
||||
return base;
|
||||
}
|
||||
if (s.title) return s.title;
|
||||
if (s.type === 'Audio' && s.channels) return `${s.channels}ch ${s.channel_layout ?? ''}`.trim();
|
||||
return s.language ? langName(s.language) : '';
|
||||
}
|
||||
|
||||
// ─── Stream table ─────────────────────────────────────────────────────────────
|
||||
|
||||
const STREAM_SECTIONS = [
|
||||
{ type: 'Video', label: 'Video' },
|
||||
{ type: 'Audio', label: 'Audio' },
|
||||
{ type: 'Subtitle', label: 'Subtitles — all extracted to sidecar files' },
|
||||
{ type: 'Data', label: 'Data' },
|
||||
{ type: 'EmbeddedImage', label: 'Embedded Images' },
|
||||
];
|
||||
|
||||
const TYPE_ORDER: Record<string, number> = { Video: 0, Audio: 1, Subtitle: 2, Data: 3, EmbeddedImage: 4 };
|
||||
|
||||
function computeOutIdx(streams: MediaStream[], decisions: StreamDecision[]): Map<number, number> {
|
||||
const mappedKept = streams
|
||||
.filter((s) => ['Video', 'Audio'].includes(s.type))
|
||||
.filter((s) => {
|
||||
const action = decisions.find((d) => d.stream_id === s.id)?.action;
|
||||
return action === 'keep';
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const ta = TYPE_ORDER[a.type] ?? 9;
|
||||
const tb = TYPE_ORDER[b.type] ?? 9;
|
||||
if (ta !== tb) return ta - tb;
|
||||
const da = decisions.find((d) => d.stream_id === a.id);
|
||||
const db = decisions.find((d) => d.stream_id === b.id);
|
||||
return (da?.target_index ?? 0) - (db?.target_index ?? 0);
|
||||
});
|
||||
const m = new Map<number, number>();
|
||||
mappedKept.forEach((s, i) => m.set(s.id, i));
|
||||
return m;
|
||||
}
|
||||
|
||||
interface StreamTableProps { data: DetailData; onUpdate: (d: DetailData) => void; }
|
||||
|
||||
function StreamTable({ data, onUpdate }: StreamTableProps) {
|
||||
const { item, streams, plan, decisions } = data;
|
||||
const outIdx = computeOutIdx(streams, decisions);
|
||||
|
||||
const toggleStream = async (streamId: number, currentAction: 'keep' | 'remove') => {
|
||||
const d = await api.patch<DetailData>(`/api/review/${item.id}/stream/${streamId}`, { action: currentAction === 'keep' ? 'remove' : 'keep' });
|
||||
onUpdate(d);
|
||||
};
|
||||
|
||||
const updateTitle = async (streamId: number, title: string) => {
|
||||
const d = await api.patch<DetailData>(`/api/review/${item.id}/stream/${streamId}/title`, { title });
|
||||
onUpdate(d);
|
||||
};
|
||||
|
||||
return (
|
||||
<table className="w-full border-collapse text-[0.79rem] mt-1">
|
||||
<thead>
|
||||
<tr>
|
||||
{['Out', 'Codec', 'Language', 'Title / Info', 'Flags', 'Action'].map((h) => (
|
||||
<th key={h} className="text-left text-[0.67rem] uppercase tracking-[0.05em] text-gray-500 py-1 px-2 border-b border-gray-200">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{STREAM_SECTIONS.flatMap(({ type, label }) => {
|
||||
const group = streams.filter((s) => s.type === type);
|
||||
if (group.length === 0) return [];
|
||||
return [
|
||||
<tr key={`hdr-${type}`}>
|
||||
<td colSpan={6} className="text-[0.67rem] font-bold uppercase tracking-[0.06em] text-gray-500 bg-gray-50 py-0.5 px-2 border-b border-gray-100">
|
||||
{label}
|
||||
</td>
|
||||
</tr>,
|
||||
...group.map((s) => {
|
||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||
const action = dec?.action ?? 'keep';
|
||||
const isSub = s.type === 'Subtitle';
|
||||
const isAudio = s.type === 'Audio';
|
||||
|
||||
const outputNum = outIdx.get(s.id);
|
||||
const lbl = effectiveLabel(s, dec);
|
||||
const origTitle = s.title;
|
||||
const lang = langName(s.language);
|
||||
// Only audio streams can be edited; subtitles are always extracted
|
||||
const isEditable = plan?.status === 'pending' && isAudio;
|
||||
const rowBg = isSub ? 'bg-sky-50' : action === 'keep' ? 'bg-green-50' : 'bg-red-50';
|
||||
|
||||
return (
|
||||
<tr key={s.id} className={rowBg}>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">
|
||||
{isSub ? <span className="text-gray-400">—</span> : outputNum !== undefined ? outputNum : <span className="text-gray-400">—</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.codec ?? '—'}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
{lang} {s.language ? <span className="text-gray-400 font-mono text-xs">({s.language})</span> : null}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
{isEditable ? (
|
||||
<TitleInput
|
||||
value={lbl}
|
||||
onCommit={(v) => updateTitle(s.id, v)}
|
||||
/>
|
||||
) : (
|
||||
<span>{lbl || '—'}</span>
|
||||
)}
|
||||
{isEditable && origTitle && origTitle !== lbl && (
|
||||
<div className="text-gray-400 text-[0.7rem] mt-0.5">orig: {origTitle}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
<span className="inline-flex gap-1">
|
||||
{s.is_default ? <Badge>default</Badge> : null}
|
||||
{s.is_forced ? <Badge variant="manual">forced</Badge> : null}
|
||||
{s.is_hearing_impaired ? <Badge>CC</Badge> : null}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
{isSub ? (
|
||||
<span className="inline-block border-0 rounded px-2 py-0.5 text-[0.72rem] font-semibold bg-sky-600 text-white min-w-[4.5rem]">
|
||||
↑ Extract
|
||||
</span>
|
||||
) : plan?.status === 'pending' && (isAudio) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleStream(s.id, action)}
|
||||
className={`border-0 rounded px-2 py-0.5 text-[0.72rem] font-semibold cursor-pointer min-w-[4.5rem] ${action === 'keep' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'}`}
|
||||
>
|
||||
{action === 'keep' ? '✓ Keep' : '✗ Remove'}
|
||||
</button>
|
||||
) : (
|
||||
<Badge variant={action === 'keep' ? 'keep' : 'remove'}>{action}</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}),
|
||||
];
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
|
||||
const [localVal, setLocalVal] = useState(value);
|
||||
useEffect(() => { setLocalVal(value); }, [value]);
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={localVal}
|
||||
onChange={(e) => setLocalVal(e.target.value)}
|
||||
onBlur={(e) => { if (e.target.value !== value) onCommit(e.target.value); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
|
||||
placeholder="—"
|
||||
className="border border-transparent bg-transparent w-full text-[inherit] font-[inherit] text-inherit px-[0.2em] py-[0.05em] rounded outline-none hover:border-gray-300 hover:bg-gray-50 focus:border-blue-300 focus:bg-white min-w-16"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Detail page ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function AudioDetailPage() {
|
||||
const { id } = useParams({ from: '/review/audio/$id' });
|
||||
const [data, setData] = useState<DetailData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rescanning, setRescanning] = useState(false);
|
||||
|
||||
const load = () => api.get<DetailData>(`/api/review/${id}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
|
||||
useEffect(() => { load(); }, [id]);
|
||||
|
||||
const setLanguage = async (lang: string) => {
|
||||
const d = await api.patch<DetailData>(`/api/review/${id}/language`, { language: lang || null });
|
||||
setData(d);
|
||||
};
|
||||
|
||||
const approve = async () => { await api.post(`/api/review/${id}/approve`); load(); };
|
||||
const skip = async () => { await api.post(`/api/review/${id}/skip`); load(); };
|
||||
const unskip = async () => { await api.post(`/api/review/${id}/unskip`); load(); };
|
||||
const rescan = async () => {
|
||||
setRescanning(true);
|
||||
try { const d = await api.post<DetailData>(`/api/review/${id}/rescan`); setData(d); }
|
||||
finally { setRescanning(false); }
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
||||
if (!data) return <Alert variant="error">Item not found.</Alert>;
|
||||
|
||||
const { item, plan, command, dockerCommand, dockerMountDir } = data;
|
||||
const statusKey = plan?.is_noop ? 'noop' : (plan?.status ?? 'pending');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h1 className="text-xl font-bold m-0">
|
||||
<Link to="/review/audio" className="font-normal mr-2 no-underline text-gray-500 hover:text-gray-700">← Audio</Link>
|
||||
{item.name}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 rounded-lg p-4 mb-3">
|
||||
{/* Meta */}
|
||||
<dl className="flex flex-wrap gap-5 mb-3 text-[0.82rem]">
|
||||
{[
|
||||
{ label: 'Type', value: item.type },
|
||||
...(item.series_name ? [{ label: 'Series', value: item.series_name }] : []),
|
||||
...(item.year ? [{ label: 'Year', value: String(item.year) }] : []),
|
||||
{ label: 'Container', value: item.container ?? '—' },
|
||||
{ label: 'File size', value: item.file_size ? formatBytes(item.file_size) : '—' },
|
||||
{ label: 'Status', value: <Badge variant={statusKey as 'noop' | 'pending' | 'approved' | 'skipped' | 'done' | 'error'}>{statusKey}</Badge> },
|
||||
].map((entry, i) => (
|
||||
<div key={i}>
|
||||
<dt className="text-gray-500 text-[0.68rem] uppercase tracking-[0.05em] mb-0.5">{entry.label}</dt>
|
||||
<dd className="m-0 font-medium">{entry.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="font-mono text-gray-400 text-[0.78rem] mb-4 break-all">{item.file_path}</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{plan?.notes && <Alert variant="warning" className="mb-3">{plan.notes}</Alert>}
|
||||
{item.needs_review && !item.original_language && (
|
||||
<Alert variant="warning" className="mb-3">
|
||||
Original language unknown — audio tracks will NOT be filtered until you set it below.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Language override */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<label className="text-[0.85rem] m-0">Original language:</label>
|
||||
<Select value={item.original_language ?? ''} onChange={(e) => setLanguage(e.target.value)} className="text-[0.79rem] py-0.5 px-1.5 w-auto">
|
||||
<option value="">— Unknown —</option>
|
||||
{Object.entries(LANG_NAMES).map(([code, name]) => (
|
||||
<option key={code} value={code}>{name} ({code})</option>
|
||||
))}
|
||||
</Select>
|
||||
{item.orig_lang_source && <Badge>{item.orig_lang_source}</Badge>}
|
||||
</div>
|
||||
|
||||
{/* Stream table */}
|
||||
<StreamTable data={data} onUpdate={setData} />
|
||||
|
||||
{/* FFmpeg command */}
|
||||
{command && (
|
||||
<div className="mt-6">
|
||||
<div className="text-gray-400 text-[0.75rem] uppercase tracking-[0.05em] mb-1">FFmpeg command (audio + subtitle extraction)</div>
|
||||
<textarea
|
||||
readOnly
|
||||
rows={3}
|
||||
value={command}
|
||||
className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#9cdcfe] p-3 rounded w-full resize-y border-0 min-h-10"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dockerCommand && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-baseline gap-2 mb-1 flex-wrap">
|
||||
<span className="text-gray-400 text-[0.75rem] uppercase tracking-[0.05em]">Docker (fallback)</span>
|
||||
<span className="text-gray-400 text-[0.7rem]">— mount: <code className="font-mono">{dockerMountDir}:/work</code></span>
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
rows={3}
|
||||
value={dockerCommand}
|
||||
className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#9cdcfe] p-3 rounded w-full resize-y border-0 min-h-10"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{plan?.status === 'pending' && !plan.is_noop && (
|
||||
<div className="flex gap-2 mt-6">
|
||||
<Button onClick={approve}>✓ Approve</Button>
|
||||
<Button variant="secondary" onClick={skip}>Skip</Button>
|
||||
</div>
|
||||
)}
|
||||
{plan?.status === 'skipped' && (
|
||||
<div className="mt-6">
|
||||
<Button variant="secondary" onClick={unskip}>Unskip</Button>
|
||||
</div>
|
||||
)}
|
||||
{plan?.is_noop ? (
|
||||
<Alert variant="success" className="mt-4">Audio is already clean — no audio changes needed.</Alert>
|
||||
) : null}
|
||||
|
||||
{/* Refresh */}
|
||||
<div className="flex items-center gap-3 mt-6 pt-3 border-t border-gray-200">
|
||||
<Button variant="secondary" size="sm" onClick={rescan} disabled={rescanning}>
|
||||
{rescanning ? '↻ Refreshing…' : '↻ Refresh from Jellyfin'}
|
||||
</Button>
|
||||
<span className="text-gray-400 text-[0.75rem]">
|
||||
{rescanning ? 'Triggering Jellyfin metadata probe and waiting for completion…' : 'Triggers a metadata re-probe in Jellyfin, then re-fetches stream data'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
300
src/features/review/AudioListPage.tsx
Normal file
300
src/features/review/AudioListPage.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
import { langName } from '~/shared/lib/lang';
|
||||
import type { MediaItem, ReviewPlan } from '~/shared/lib/types';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface MovieRow { item: MediaItem; plan: ReviewPlan | null; removeCount: number; keepCount: number; }
|
||||
|
||||
interface SeriesGroup {
|
||||
series_key: string; series_name: string; original_language: string | null;
|
||||
season_count: number; episode_count: number;
|
||||
noop_count: number; needs_action_count: number; approved_count: number;
|
||||
skipped_count: number; done_count: number; error_count: number; manual_count: number;
|
||||
}
|
||||
|
||||
interface ReviewListData {
|
||||
movies: MovieRow[];
|
||||
series: SeriesGroup[];
|
||||
filter: string;
|
||||
totalCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
// ─── Filter tabs ──────────────────────────────────────────────────────────────
|
||||
|
||||
const FILTER_TABS = [
|
||||
{ key: 'all', label: 'All' }, { key: 'needs_action', label: 'Needs Action' },
|
||||
{ key: 'noop', label: 'No Change' }, { key: 'manual', label: 'Manual Review' },
|
||||
{ key: 'approved', label: 'Approved' }, { key: 'skipped', label: 'Skipped' },
|
||||
{ key: 'done', label: 'Done' }, { key: 'error', label: 'Error' },
|
||||
];
|
||||
|
||||
// ─── Status pills ─────────────────────────────────────────────────────────────
|
||||
|
||||
function StatusPills({ g }: { g: SeriesGroup }) {
|
||||
return (
|
||||
<span className="inline-flex flex-wrap gap-1 items-center">
|
||||
{g.noop_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">{g.noop_count} ok</span>}
|
||||
{g.needs_action_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-800">{g.needs_action_count} action</span>}
|
||||
{g.approved_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-green-100 text-green-800">{g.approved_count} approved</span>}
|
||||
{g.done_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-cyan-100 text-cyan-800">{g.done_count} done</span>}
|
||||
{g.error_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-800">{g.error_count} err</span>}
|
||||
{g.skipped_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">{g.skipped_count} skip</span>}
|
||||
{g.manual_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-orange-100 text-orange-800">{g.manual_count} manual</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Th helper ───────────────────────────────────────────────────────────────
|
||||
|
||||
const Th = ({ children }: { children: React.ReactNode }) => (
|
||||
<th className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
|
||||
const Td = ({ children, className }: { children?: React.ReactNode; className?: string }) => (
|
||||
<td className={`py-1.5 px-2 border-b border-gray-100 align-middle ${className ?? ''}`}>{children}</td>
|
||||
);
|
||||
|
||||
// ─── Series row (collapsible) ─────────────────────────────────────────────────
|
||||
|
||||
function SeriesRow({ g }: { g: SeriesGroup }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const urlKey = encodeURIComponent(g.series_key);
|
||||
|
||||
interface EpisodeItem { item: MediaItem; plan: ReviewPlan | null; removeCount: number; }
|
||||
interface SeasonGroup { season: number | null; episodes: EpisodeItem[]; noopCount: number; actionCount: number; approvedCount: number; doneCount: number; }
|
||||
|
||||
const [seasons, setSeasons] = useState<SeasonGroup[] | null>(null);
|
||||
|
||||
const toggle = async () => {
|
||||
if (!open && seasons === null) {
|
||||
const data = await api.get<{ seasons: SeasonGroup[] }>(`/api/review/series/${urlKey}/episodes`);
|
||||
setSeasons(data.seasons);
|
||||
}
|
||||
setOpen((v) => !v);
|
||||
};
|
||||
|
||||
const approveAll = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await api.post(`/api/review/series/${urlKey}/approve-all`);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const approveSeason = async (e: React.MouseEvent, season: number | null) => {
|
||||
e.stopPropagation();
|
||||
await api.post(`/api/review/season/${urlKey}/${season ?? 0}/approve-all`);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const statusKey = (plan: ReviewPlan | null) => plan?.is_noop ? 'noop' : (plan?.status ?? 'pending');
|
||||
|
||||
return (
|
||||
<tbody>
|
||||
<tr
|
||||
className="cursor-pointer hover:bg-gray-50"
|
||||
onClick={toggle}
|
||||
>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-medium">
|
||||
<span className={`inline-flex items-center justify-center w-4 h-4 rounded text-[0.65rem] text-gray-500 transition-transform ${open ? 'rotate-90' : ''}`}>▶</span>
|
||||
{' '}<strong>{g.series_name}</strong>
|
||||
</td>
|
||||
<Td>{langName(g.original_language)}</Td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.season_count}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.episode_count}</td>
|
||||
<Td><StatusPills g={g} /></Td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
|
||||
{g.needs_action_count > 0 && (
|
||||
<Button size="xs" onClick={approveAll}>Approve all</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{open && seasons && (
|
||||
<tr>
|
||||
<td colSpan={6} className="p-0 border-b border-gray-100">
|
||||
<table className="w-full border-collapse">
|
||||
<tbody>
|
||||
{seasons.map((s) => (
|
||||
<>
|
||||
<tr key={`season-${s.season}`} className="bg-gray-50">
|
||||
<td colSpan={4} className="text-[0.7rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-0.5 px-8 border-b border-gray-100">
|
||||
Season {s.season ?? '?'}
|
||||
<span className="ml-3 inline-flex gap-1">
|
||||
{s.noopCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-gray-200 text-gray-600 text-[0.7rem]">{s.noopCount} ok</span>}
|
||||
{s.actionCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-red-100 text-red-800 text-[0.7rem]">{s.actionCount} action</span>}
|
||||
{s.approvedCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-800 text-[0.7rem]">{s.approvedCount} approved</span>}
|
||||
{s.doneCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-cyan-100 text-cyan-800 text-[0.7rem]">{s.doneCount} done</span>}
|
||||
</span>
|
||||
{s.actionCount > 0 && (
|
||||
<Button size="xs" variant="secondary" className="ml-3" onClick={(e) => approveSeason(e, s.season)}>
|
||||
Approve season
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{s.episodes.map(({ item, plan, removeCount }) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem] pl-10">
|
||||
<span className="text-gray-400 font-mono text-xs">E{String(item.episode_number ?? 0).padStart(2, '0')}</span>
|
||||
{' '}
|
||||
<span className="truncate inline-block max-w-xs align-bottom">{item.name}</span>
|
||||
</td>
|
||||
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem]">
|
||||
{removeCount > 0 ? <Badge variant="remove">−{removeCount}</Badge> : <span className="text-gray-400">—</span>}
|
||||
</td>
|
||||
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem]">
|
||||
<Badge variant={statusKey(plan) as 'noop' | 'pending' | 'approved' | 'skipped' | 'done' | 'error'}>{plan?.is_noop ? 'ok' : (plan?.status ?? 'pending')}</Badge>
|
||||
</td>
|
||||
<td className="py-1 px-2 border-b border-gray-100 whitespace-nowrap flex gap-1 items-center">
|
||||
{plan?.status === 'pending' && !plan.is_noop && (
|
||||
<ApproveBtn itemId={item.id} size="xs" />
|
||||
)}
|
||||
{plan?.status === 'pending' && (
|
||||
<SkipBtn itemId={item.id} size="xs" />
|
||||
)}
|
||||
{plan?.status === 'skipped' && (
|
||||
<UnskipBtn itemId={item.id} size="xs" />
|
||||
)}
|
||||
<Link to="/review/audio/$id" params={{ id: String(item.id) }} className="inline-flex items-center justify-center gap-1 rounded px-2 py-0.5 text-xs font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline">
|
||||
Detail
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Action buttons ───────────────────────────────────────────────────────────
|
||||
|
||||
function ApproveBtn({ itemId, size }: { itemId: number; size?: 'xs' | 'sm' }) {
|
||||
const onClick = async () => { await api.post(`/api/review/${itemId}/approve`); window.location.reload(); };
|
||||
return <Button size={size ?? 'xs'} onClick={onClick}>Approve</Button>;
|
||||
}
|
||||
|
||||
function SkipBtn({ itemId, size }: { itemId: number; size?: 'xs' | 'sm' }) {
|
||||
const onClick = async () => { await api.post(`/api/review/${itemId}/skip`); window.location.reload(); };
|
||||
return <Button size={size ?? 'xs'} variant="secondary" onClick={onClick}>Skip</Button>;
|
||||
}
|
||||
|
||||
function UnskipBtn({ itemId, size }: { itemId: number; size?: 'xs' | 'sm' }) {
|
||||
const onClick = async () => { await api.post(`/api/review/${itemId}/unskip`); window.location.reload(); };
|
||||
return <Button size={size ?? 'xs'} variant="secondary" onClick={onClick}>Unskip</Button>;
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function AudioListPage() {
|
||||
const { filter } = useSearch({ from: '/review/audio/' });
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<ReviewListData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<ReviewListData>(`/api/review?filter=${filter}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
|
||||
}, [filter]);
|
||||
|
||||
const approveAll = async () => {
|
||||
await api.post('/api/review/approve-all');
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
||||
if (!data) return <div className="text-red-600">Failed to load.</div>;
|
||||
|
||||
const { movies, series, totalCounts } = data;
|
||||
const hasPending = (totalCounts.needs_action ?? 0) > 0;
|
||||
const statusKey = (plan: ReviewPlan | null) => plan?.is_noop ? 'noop' : (plan?.status ?? 'pending');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<h1 className="text-xl font-bold m-0">Audio Review</h1>
|
||||
{hasPending && <Button size="sm" onClick={approveAll}>Approve all pending</Button>}
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex gap-1 flex-wrap mb-3 items-center">
|
||||
{FILTER_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => navigate({ to: '/review/audio', search: { filter: tab.key } as never })}
|
||||
className={`px-2.5 py-0.5 rounded text-[0.8rem] border cursor-pointer transition-colors leading-[1.4] ${filter === tab.key ? 'bg-blue-600 border-blue-600 text-white' : 'border-gray-200 bg-transparent text-gray-500 hover:bg-gray-50'}`}
|
||||
>
|
||||
{tab.label}
|
||||
{totalCounts[tab.key] != null && <> <span className="text-[0.72rem] font-bold">{totalCounts[tab.key]}</span></>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{movies.length === 0 && series.length === 0 && (
|
||||
<p className="text-gray-500">No items match this filter.</p>
|
||||
)}
|
||||
|
||||
{/* Movies */}
|
||||
{movies.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mt-5 mb-1.5 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-gray-500">
|
||||
Movies <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{movies.length}</span>
|
||||
</div>
|
||||
<table className="w-full border-collapse text-[0.82rem]">
|
||||
<thead><tr><Th>Name</Th><Th>Lang</Th><Th>Remove</Th><Th>Status</Th><Th>Actions</Th></tr></thead>
|
||||
<tbody>
|
||||
{movies.map(({ item, plan, removeCount }) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<Td>
|
||||
<span className="truncate inline-block max-w-[360px]" title={item.name}>{item.name}</span>
|
||||
{item.year && <span className="text-gray-400 text-[0.72rem]"> ({item.year})</span>}
|
||||
</Td>
|
||||
<Td>
|
||||
{item.needs_review && !item.original_language
|
||||
? <Badge variant="manual">manual</Badge>
|
||||
: <span>{langName(item.original_language)}</span>}
|
||||
</Td>
|
||||
<Td>{removeCount > 0 ? <Badge variant="remove">−{removeCount}</Badge> : <span className="text-gray-400">—</span>}</Td>
|
||||
<Td><Badge variant={statusKey(plan) as 'noop' | 'pending' | 'approved' | 'skipped' | 'done' | 'error'}>{plan?.is_noop ? 'ok' : (plan?.status ?? 'pending')}</Badge></Td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap flex gap-1 items-center">
|
||||
{plan?.status === 'pending' && !plan.is_noop && <ApproveBtn itemId={item.id} />}
|
||||
{plan?.status === 'pending' && <SkipBtn itemId={item.id} />}
|
||||
{plan?.status === 'skipped' && <UnskipBtn itemId={item.id} />}
|
||||
<Link to="/review/audio/$id" params={{ id: String(item.id) }} className="inline-flex items-center justify-center gap-1 rounded px-2 py-0.5 text-xs font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline">
|
||||
Detail
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* TV Series */}
|
||||
{series.length > 0 && (
|
||||
<>
|
||||
<div className={`flex items-center gap-2 mb-1.5 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-gray-500 ${movies.length > 0 ? 'mt-5' : 'mt-0'}`}>
|
||||
TV Series <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{series.length}</span>
|
||||
</div>
|
||||
<table className="w-full border-collapse text-[0.82rem]">
|
||||
<thead><tr><Th>Series</Th><Th>Lang</Th><Th>S</Th><Th>Ep</Th><Th>Status</Th><Th>Actions</Th></tr></thead>
|
||||
{series.map((g) => <SeriesRow key={g.series_key} g={g} />)}
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import type React from 'react';
|
||||
155
src/features/scan/ScanPage.tsx
Normal file
155
src/features/scan/ScanPage.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
|
||||
interface Progress { scanned: number; total: number; errors: number; }
|
||||
interface ScanStatus { running: boolean; progress: Progress; recentItems: { name: string; type: string; scan_status: string }[]; scanLimit: number | null; }
|
||||
interface LogEntry { name: string; type: string; status: string; }
|
||||
|
||||
export function ScanPage() {
|
||||
const [status, setStatus] = useState<ScanStatus | null>(null);
|
||||
const [limit, setLimit] = useState('');
|
||||
const [log, setLog] = useState<LogEntry[]>([]);
|
||||
const [statusLabel, setStatusLabel] = useState('');
|
||||
const [currentItem, setCurrentItem] = useState('');
|
||||
const [progressScanned, setProgressScanned] = useState(0);
|
||||
const [progressTotal, setProgressTotal] = useState(0);
|
||||
const [errors, setErrors] = useState(0);
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
const s = await api.get<ScanStatus>('/api/scan');
|
||||
setStatus(s);
|
||||
setProgressScanned(s.progress.scanned);
|
||||
setProgressTotal(s.progress.total);
|
||||
setErrors(s.progress.errors);
|
||||
setStatusLabel(s.running ? 'Scan in progress…' : 'Scan idle');
|
||||
if (s.scanLimit != null) setLimit(String(s.scanLimit));
|
||||
setLog(s.recentItems.map((i) => ({ name: i.name, type: i.type, status: i.scan_status })));
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
// Connect SSE when running
|
||||
useEffect(() => {
|
||||
if (!status?.running) return;
|
||||
const es = new EventSource('/api/scan/events');
|
||||
esRef.current = es;
|
||||
|
||||
es.addEventListener('progress', (e) => {
|
||||
const d = JSON.parse(e.data) as { scanned: number; total: number; errors: number; current_item: string };
|
||||
setProgressScanned(d.scanned);
|
||||
setProgressTotal(d.total);
|
||||
setErrors(d.errors);
|
||||
setCurrentItem(d.current_item ?? '');
|
||||
});
|
||||
|
||||
es.addEventListener('log', (e) => {
|
||||
const d = JSON.parse(e.data) as LogEntry;
|
||||
setLog((prev) => [d, ...prev].slice(0, 100));
|
||||
});
|
||||
|
||||
es.addEventListener('complete', (e) => {
|
||||
const d = JSON.parse(e.data || '{}') as { scanned?: number; errors?: number };
|
||||
es.close();
|
||||
setStatusLabel(`Scan complete — ${d.scanned ?? '?'} items, ${d.errors ?? 0} errors`);
|
||||
setStatus((prev) => prev ? { ...prev, running: false } : prev);
|
||||
});
|
||||
|
||||
es.addEventListener('error', () => {
|
||||
es.close();
|
||||
setStatusLabel('Scan connection lost — refresh to see current status');
|
||||
});
|
||||
|
||||
return () => es.close();
|
||||
}, [status?.running]);
|
||||
|
||||
const startScan = async () => {
|
||||
const limitNum = limit ? Number(limit) : undefined;
|
||||
await api.post('/api/scan/start', limitNum !== undefined ? { limit: limitNum } : {});
|
||||
setStatus((prev) => prev ? { ...prev, running: true } : prev);
|
||||
setStatusLabel('Scan in progress…');
|
||||
setLog([]);
|
||||
};
|
||||
|
||||
const stopScan = async () => {
|
||||
await api.post('/api/scan/stop', {});
|
||||
esRef.current?.close();
|
||||
setStatus((prev) => prev ? { ...prev, running: false } : prev);
|
||||
setStatusLabel('Scan stopped');
|
||||
};
|
||||
|
||||
const pct = progressTotal > 0 ? Math.round((progressScanned / progressTotal) * 100) : 0;
|
||||
const running = status?.running ?? false;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<h1 className="text-xl font-bold m-0">Library Scan</h1>
|
||||
</div>
|
||||
|
||||
{/* Status card */}
|
||||
<div className="border border-gray-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center flex-wrap gap-2 mb-4">
|
||||
<strong>{statusLabel || (running ? 'Scan in progress…' : 'Scan idle')}</strong>
|
||||
{running ? (
|
||||
<Button variant="secondary" size="sm" onClick={stopScan}>Stop</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1.5 text-xs m-0">
|
||||
Limit
|
||||
<input
|
||||
type="number"
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(e.target.value)}
|
||||
placeholder="all"
|
||||
min="1"
|
||||
className="border border-gray-300 rounded px-1.5 py-0.5 text-xs w-16"
|
||||
/>
|
||||
items
|
||||
</label>
|
||||
<Button size="sm" onClick={startScan}>Start Scan</Button>
|
||||
</div>
|
||||
)}
|
||||
{errors > 0 && <Badge variant="error">{errors} error(s)</Badge>}
|
||||
</div>
|
||||
|
||||
{(running || progressTotal > 0) && (
|
||||
<>
|
||||
<div className="bg-gray-200 rounded-full h-1.5 overflow-hidden my-2">
|
||||
<div className="h-full bg-blue-600 rounded-full transition-all duration-300" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-500 text-xs">
|
||||
<span>{progressScanned} / {progressTotal}</span>
|
||||
{currentItem && <span className="truncate max-w-xs text-gray-400">{currentItem}</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Log */}
|
||||
<h3 className="font-semibold text-sm mb-2">Recent items</h3>
|
||||
<table className="w-full border-collapse text-[0.82rem]">
|
||||
<thead>
|
||||
<tr>
|
||||
{['Type', 'Name', 'Status'].map((h) => (
|
||||
<th key={h} className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{log.map((item, i) => (
|
||||
<tr key={i} className="hover:bg-gray-50">
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">{item.type}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">{item.name}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
<Badge variant={item.status as 'error' | 'done' | 'pending'}>{item.status}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
242
src/features/setup/SetupPage.tsx
Normal file
242
src/features/setup/SetupPage.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
import { Input } from '~/shared/components/ui/input';
|
||||
import { Alert } from '~/shared/components/ui/alert';
|
||||
|
||||
interface SetupData { config: Record<string, string>; envLocked: string[]; }
|
||||
|
||||
const SUBTITLE_OPTIONS = [
|
||||
{ code: 'eng', label: 'English' },
|
||||
{ code: 'deu', label: 'German (Deutsch)' },
|
||||
{ code: 'spa', label: 'Spanish (Español)' },
|
||||
{ code: 'fra', label: 'French (Français)' },
|
||||
{ code: 'ita', label: 'Italian (Italiano)' },
|
||||
{ code: 'por', label: 'Portuguese' },
|
||||
{ code: 'jpn', label: 'Japanese' },
|
||||
];
|
||||
|
||||
// ─── Locked input ─────────────────────────────────────────────────────────────
|
||||
|
||||
function LockedInput({ locked, ...props }: { locked: boolean } & React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input {...props} disabled={locked || props.disabled} className={locked ? 'pr-9' : ''} />
|
||||
{locked && (
|
||||
<span
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[0.9rem] opacity-40 pointer-events-none select-none"
|
||||
title="Set via environment variable — edit your .env file to change this value"
|
||||
>
|
||||
🔒
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Env badge ────────────────────────────────────────────────────────────────
|
||||
|
||||
function EnvBadge({ envVar, locked }: { envVar: string; locked: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[0.67rem] font-semibold px-1.5 py-0.5 rounded bg-gray-100 text-gray-500 border border-gray-200"
|
||||
title={locked
|
||||
? `Set via environment variable ${envVar} — edit your .env file to change`
|
||||
: `Can be set via environment variable ${envVar}`}
|
||||
>
|
||||
{locked ? '🔒' : '🔓'} <span className="font-mono">{envVar}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section card ──────────────────────────────────────────────────────────────
|
||||
|
||||
function SectionCard({ title, subtitle, children }: { title: React.ReactNode; subtitle?: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-4 mb-4">
|
||||
<div className="font-semibold text-sm mb-1">{title}</div>
|
||||
{subtitle && <p className="text-gray-500 text-sm mb-3 mt-0">{subtitle}</p>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Connection section ────────────────────────────────────────────────────────
|
||||
|
||||
function ConnSection({
|
||||
title, subtitle, cfg, locked, urlKey, apiKey: apiKeyProp, urlPlaceholder, onSave,
|
||||
}: {
|
||||
title: React.ReactNode; subtitle?: React.ReactNode; cfg: Record<string, string>; locked: Set<string>;
|
||||
urlKey: string; apiKey: string; urlPlaceholder: string; onSave: (url: string, apiKey: string) => Promise<void>;
|
||||
}) {
|
||||
const [url, setUrl] = useState(cfg[urlKey] ?? '');
|
||||
const [key, setKey] = useState(cfg[apiKeyProp] ?? '');
|
||||
const [status, setStatus] = useState<{ ok: boolean; error?: string } | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setStatus(null);
|
||||
try { await onSave(url, key); setStatus({ ok: true }); } catch (e) { setStatus({ ok: false, error: String(e) }); }
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionCard title={title} subtitle={subtitle}>
|
||||
<label className="block text-sm text-gray-700 mb-1">
|
||||
URL
|
||||
<LockedInput locked={locked.has(urlKey)} type="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder={urlPlaceholder} className="mt-0.5 max-w-sm" />
|
||||
</label>
|
||||
<label className="block text-sm text-gray-700 mb-1 mt-3">
|
||||
API Key
|
||||
<LockedInput locked={locked.has(apiKeyProp)} value={key} onChange={(e) => setKey(e.target.value)} placeholder="your-api-key" className="mt-0.5 max-w-xs" />
|
||||
</label>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<Button onClick={save} disabled={saving || (locked.has(urlKey) && locked.has(apiKeyProp))}>
|
||||
{saving ? 'Saving…' : 'Test & Save'}
|
||||
</Button>
|
||||
{status && (
|
||||
<span className={`text-sm ${status.ok ? 'text-green-700' : 'text-red-600'}`}>
|
||||
{status.ok ? '✓ Saved' : `✗ ${status.error ?? 'Connection failed'}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Setup page ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function SetupPage() {
|
||||
const [data, setData] = useState<SetupData | null>(null);
|
||||
const [clearStatus, setClearStatus] = useState('');
|
||||
|
||||
const load = () => api.get<SetupData>('/api/setup').then(setData);
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
if (!data) return <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
||||
|
||||
const { config: cfg, envLocked: envLockedArr } = data;
|
||||
const locked = new Set(envLockedArr);
|
||||
const subtitleLangs: string[] = JSON.parse(cfg.subtitle_languages ?? '["eng","deu","spa"]');
|
||||
|
||||
const saveJellyfin = (url: string, apiKey: string) =>
|
||||
api.post('/api/setup/jellyfin', { url, api_key: apiKey });
|
||||
const saveRadarr = (url: string, apiKey: string) =>
|
||||
api.post('/api/setup/radarr', { url, api_key: apiKey });
|
||||
const saveSonarr = (url: string, apiKey: string) =>
|
||||
api.post('/api/setup/sonarr', { url, api_key: apiKey });
|
||||
|
||||
const saveSubtitleLangs = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.currentTarget);
|
||||
const langs = fd.getAll('subtitle_lang') as string[];
|
||||
await api.post('/api/setup/subtitle-languages', { langs });
|
||||
};
|
||||
|
||||
const savePaths = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.currentTarget);
|
||||
await api.post('/api/setup/paths', { movies_path: fd.get('movies_path'), series_path: fd.get('series_path') });
|
||||
};
|
||||
|
||||
const clearScan = async () => {
|
||||
if (!confirm('Delete all scanned data? This will remove all media items, stream decisions, review plans, and jobs. This cannot be undone.')) return;
|
||||
await api.post('/api/setup/clear-scan');
|
||||
setClearStatus('Cleared.');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<h1 className="text-xl font-bold m-0">Settings</h1>
|
||||
</div>
|
||||
|
||||
{/* Jellyfin */}
|
||||
<ConnSection
|
||||
title={<span className="flex items-center gap-2">Jellyfin <EnvBadge envVar="JELLYFIN_URL" locked={locked.has('jellyfin_url')} /> <EnvBadge envVar="JELLYFIN_API_KEY" locked={locked.has('jellyfin_api_key')} /></span>}
|
||||
urlKey="jellyfin_url" apiKey="jellyfin_api_key"
|
||||
urlPlaceholder="http://192.168.1.100:8096" cfg={cfg} locked={locked}
|
||||
onSave={saveJellyfin}
|
||||
/>
|
||||
|
||||
{/* Radarr */}
|
||||
<ConnSection
|
||||
title={<span className="flex items-center gap-2">Radarr <span className="text-gray-400 font-normal">(optional)</span> <EnvBadge envVar="RADARR_URL" locked={locked.has('radarr_url')} /> <EnvBadge envVar="RADARR_API_KEY" locked={locked.has('radarr_api_key')} /></span>}
|
||||
subtitle="Provides accurate original-language data for movies."
|
||||
urlKey="radarr_url" apiKey="radarr_api_key"
|
||||
urlPlaceholder="http://192.168.1.100:7878" cfg={cfg} locked={locked}
|
||||
onSave={saveRadarr}
|
||||
/>
|
||||
|
||||
{/* Sonarr */}
|
||||
<ConnSection
|
||||
title={<span className="flex items-center gap-2">Sonarr <span className="text-gray-400 font-normal">(optional)</span> <EnvBadge envVar="SONARR_URL" locked={locked.has('sonarr_url')} /> <EnvBadge envVar="SONARR_API_KEY" locked={locked.has('sonarr_api_key')} /></span>}
|
||||
subtitle="Provides original-language data for TV series."
|
||||
urlKey="sonarr_url" apiKey="sonarr_api_key"
|
||||
urlPlaceholder="http://192.168.1.100:8989" cfg={cfg} locked={locked}
|
||||
onSave={saveSonarr}
|
||||
/>
|
||||
|
||||
{/* Media paths */}
|
||||
<SectionCard title={<span className="flex items-center gap-2">Media Paths <EnvBadge envVar="MOVIES_PATH" locked={locked.has('movies_path')} /> <EnvBadge envVar="SERIES_PATH" locked={locked.has('series_path')} /></span>} subtitle={
|
||||
<>
|
||||
Host paths where your media lives on the machine running the Docker command.
|
||||
Jellyfin always exposes libraries at <code className="font-mono bg-gray-100 px-1 rounded">/movies</code> and <code className="font-mono bg-gray-100 px-1 rounded">/series</code>,
|
||||
so only the host-side path is needed to generate a correct Docker command.
|
||||
</>
|
||||
}>
|
||||
<form onSubmit={savePaths}>
|
||||
<label className="block text-sm text-gray-700 mb-1">
|
||||
Movies root path
|
||||
<LockedInput locked={locked.has('movies_path')} name="movies_path" defaultValue={cfg.movies_path ?? ''} placeholder="/mnt/user/storage/Movies" className="mt-0.5 max-w-sm" />
|
||||
<small className="text-xs text-gray-500 mt-0.5 block">Host directory mounted as <code className="font-mono">/movies</code> inside Jellyfin</small>
|
||||
</label>
|
||||
<label className="block text-sm text-gray-700 mb-1 mt-3">
|
||||
Series root path
|
||||
<LockedInput locked={locked.has('series_path')} name="series_path" defaultValue={cfg.series_path ?? ''} placeholder="/mnt/user/storage/Series" className="mt-0.5 max-w-sm" />
|
||||
<small className="text-xs text-gray-500 mt-0.5 block">Host directory mounted as <code className="font-mono">/series</code> inside Jellyfin</small>
|
||||
</label>
|
||||
<Button type="submit" className="mt-3">Save</Button>
|
||||
</form>
|
||||
</SectionCard>
|
||||
|
||||
{/* Subtitle languages */}
|
||||
<SectionCard
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
Subtitle Languages
|
||||
<EnvBadge envVar="SUBTITLE_LANGUAGES" locked={locked.has('subtitle_languages')} />
|
||||
</span>
|
||||
}
|
||||
subtitle="Subtitle tracks in these languages are kept inside the container. Forced and CC/SDH tracks are always kept regardless of language. All subtitles are extracted to sidecar files during processing."
|
||||
>
|
||||
<form onSubmit={saveSubtitleLangs}>
|
||||
<fieldset className="border border-gray-200 rounded p-3 mb-3" disabled={locked.has('subtitle_languages')}>
|
||||
<legend className="text-xs font-semibold text-gray-600 uppercase tracking-wide px-1">Keep subtitles in:</legend>
|
||||
{SUBTITLE_OPTIONS.map(({ code, label }) => (
|
||||
<label key={code} className={`flex items-center gap-2 mb-1 text-sm ${locked.has('subtitle_languages') ? 'text-gray-400' : 'text-gray-700'}`}>
|
||||
<input type="checkbox" name="subtitle_lang" value={code} defaultChecked={subtitleLangs.includes(code)} disabled={locked.has('subtitle_languages')} className="w-4 h-4 accent-blue-600 disabled:opacity-50" />
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<Button type="submit" disabled={locked.has('subtitle_languages')}>Save</Button>
|
||||
</form>
|
||||
</SectionCard>
|
||||
|
||||
{/* Danger zone */}
|
||||
<div className="border border-red-400 rounded-lg p-4 mb-4">
|
||||
<div className="font-semibold text-sm text-red-700 mb-1">Danger Zone</div>
|
||||
<p className="text-gray-500 text-sm mb-3">These actions are irreversible. Scan data can be regenerated by running a new scan.</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="danger" onClick={clearScan}>Clear all scan data</Button>
|
||||
<span className="text-gray-400 text-sm">Removes all scanned items, review plans, and jobs.</span>
|
||||
</div>
|
||||
{clearStatus && <p className="text-green-700 text-sm mt-2">{clearStatus}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import type React from 'react';
|
||||
342
src/features/subtitles/SubtitleDetailPage.tsx
Normal file
342
src/features/subtitles/SubtitleDetailPage.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useParams } from '@tanstack/react-router';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
import { Alert } from '~/shared/components/ui/alert';
|
||||
import { Select } from '~/shared/components/ui/select';
|
||||
import { langName, LANG_NAMES } from '~/shared/lib/lang';
|
||||
import type { MediaItem, MediaStream, SubtitleFile, ReviewPlan, StreamDecision } from '~/shared/lib/types';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DetailData {
|
||||
item: MediaItem;
|
||||
subtitleStreams: MediaStream[];
|
||||
files: SubtitleFile[];
|
||||
plan: ReviewPlan | null;
|
||||
decisions: StreamDecision[];
|
||||
subs_extracted: number;
|
||||
extractCommand: string | null;
|
||||
dockerCommand: string | null;
|
||||
dockerMountDir: string | null;
|
||||
}
|
||||
|
||||
// ─── Utilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function fileName(filePath: string): string {
|
||||
return filePath.split('/').pop() ?? filePath;
|
||||
}
|
||||
|
||||
function effectiveTitle(s: MediaStream, dec: StreamDecision | undefined): string {
|
||||
if (dec?.custom_title) return dec.custom_title;
|
||||
if (!s.language) return '';
|
||||
const base = langName(s.language);
|
||||
if (s.is_forced) return `${base} (Forced)`;
|
||||
if (s.is_hearing_impaired) return `${base} (CC)`;
|
||||
return base;
|
||||
}
|
||||
|
||||
// ─── Inline edit input ───────────────────────────────────────────────────────
|
||||
|
||||
function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
|
||||
const [localVal, setLocalVal] = useState(value);
|
||||
useEffect(() => { setLocalVal(value); }, [value]);
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={localVal}
|
||||
onChange={(e) => setLocalVal(e.target.value)}
|
||||
onBlur={(e) => { if (e.target.value !== value) onCommit(e.target.value); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
|
||||
placeholder="—"
|
||||
className="border border-transparent bg-transparent w-full text-[inherit] font-[inherit] text-inherit px-[0.2em] py-[0.05em] rounded outline-none hover:border-gray-300 hover:bg-gray-50 focus:border-blue-300 focus:bg-white min-w-16"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Container streams table ─────────────────────────────────────────────────
|
||||
|
||||
interface StreamTableProps {
|
||||
streams: MediaStream[];
|
||||
decisions: StreamDecision[];
|
||||
editable: boolean;
|
||||
onLanguageChange: (streamId: number, lang: string) => void;
|
||||
onTitleChange: (streamId: number, title: string) => void;
|
||||
}
|
||||
|
||||
function StreamTable({ streams, decisions, editable, onLanguageChange, onTitleChange }: StreamTableProps) {
|
||||
if (streams.length === 0) return <p className="text-gray-500 text-sm">No subtitle streams in container.</p>;
|
||||
|
||||
return (
|
||||
<table className="w-full border-collapse text-[0.79rem] mt-1">
|
||||
<thead>
|
||||
<tr>
|
||||
{['#', 'Codec', 'Language', 'Title / Info', 'Flags', 'Action'].map((h) => (
|
||||
<th key={h} className="text-left text-[0.67rem] uppercase tracking-[0.05em] text-gray-500 py-1 px-2 border-b border-gray-200">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{streams.map((s) => {
|
||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||
const title = effectiveTitle(s, dec);
|
||||
const origTitle = s.title;
|
||||
|
||||
return (
|
||||
<tr key={s.id} className="bg-sky-50">
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.stream_index}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{s.codec ?? '—'}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
{editable ? (
|
||||
<Select
|
||||
value={s.language ?? ''}
|
||||
onChange={(e) => onLanguageChange(s.id, e.target.value)}
|
||||
className="text-[0.79rem] py-0.5 px-1.5 w-auto"
|
||||
>
|
||||
<option value="">— Unknown —</option>
|
||||
{Object.entries(LANG_NAMES).map(([code, name]) => (
|
||||
<option key={code} value={code}>{name} ({code})</option>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<>
|
||||
{langName(s.language)} {s.language ? <span className="text-gray-400 font-mono text-xs">({s.language})</span> : null}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
{editable ? (
|
||||
<TitleInput
|
||||
value={title}
|
||||
onCommit={(v) => onTitleChange(s.id, v)}
|
||||
/>
|
||||
) : (
|
||||
<span>{title || '—'}</span>
|
||||
)}
|
||||
{editable && origTitle && origTitle !== title && (
|
||||
<div className="text-gray-400 text-[0.7rem] mt-0.5">orig: {origTitle}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
<span className="inline-flex gap-1">
|
||||
{s.is_default ? <Badge>default</Badge> : null}
|
||||
{s.is_forced ? <Badge variant="manual">forced</Badge> : null}
|
||||
{s.is_hearing_impaired ? <Badge>CC</Badge> : null}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
<span className="inline-block border-0 rounded px-2 py-0.5 text-[0.72rem] font-semibold bg-sky-600 text-white min-w-[4.5rem]">
|
||||
↑ Extract
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Extracted files table ────────────────────────────────────────────────────
|
||||
|
||||
function ExtractedFilesTable({ files, onDelete }: { files: SubtitleFile[]; onDelete: (fileId: number) => void }) {
|
||||
if (files.length === 0) return <p className="text-gray-500 text-sm">No extracted files yet.</p>;
|
||||
|
||||
return (
|
||||
<table className="w-full border-collapse text-[0.82rem]">
|
||||
<thead>
|
||||
<tr>
|
||||
{['File', 'Language', 'Codec', 'Flags', 'Size', ''].map((h) => (
|
||||
<th key={h} className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((f) => (
|
||||
<tr key={f.id} className="hover:bg-gray-50">
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs max-w-[360px] truncate" title={f.file_path}>
|
||||
{fileName(f.file_path)}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
{f.language ? langName(f.language) : '—'} {f.language ? <span className="text-gray-400 font-mono text-xs">({f.language})</span> : null}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{f.codec ?? '—'}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100">
|
||||
<span className="inline-flex gap-1">
|
||||
{f.is_forced ? <Badge variant="manual">forced</Badge> : null}
|
||||
{f.is_hearing_impaired ? <Badge>CC</Badge> : null}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">
|
||||
{f.file_size ? formatBytes(f.file_size) : '—'}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 text-right">
|
||||
<Button variant="danger" size="xs" onClick={() => onDelete(f.id)}>Delete</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Detail page ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function SubtitleDetailPage() {
|
||||
const { id } = useParams({ from: '/review/subtitles/$id' });
|
||||
const [data, setData] = useState<DetailData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [extracting, setExtracting] = useState(false);
|
||||
const [rescanning, setRescanning] = useState(false);
|
||||
|
||||
const load = () => api.get<DetailData>(`/api/subtitles/${id}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
|
||||
useEffect(() => { load(); }, [id]);
|
||||
|
||||
const changeLanguage = async (streamId: number, lang: string) => {
|
||||
const d = await api.patch<DetailData>(`/api/subtitles/${id}/stream/${streamId}/language`, { language: lang || null });
|
||||
setData(d);
|
||||
};
|
||||
|
||||
const changeTitle = async (streamId: number, title: string) => {
|
||||
const d = await api.patch<DetailData>(`/api/subtitles/${id}/stream/${streamId}/title`, { title });
|
||||
setData(d);
|
||||
};
|
||||
|
||||
const extract = async () => {
|
||||
setExtracting(true);
|
||||
try {
|
||||
await api.post(`/api/subtitles/${id}/extract`);
|
||||
load();
|
||||
} finally { setExtracting(false); }
|
||||
};
|
||||
|
||||
const deleteFile = async (fileId: number) => {
|
||||
const resp = await api.delete<{ ok: boolean; files: SubtitleFile[] }>(`/api/subtitles/${id}/files/${fileId}`);
|
||||
if (data) setData({ ...data, files: resp.files });
|
||||
};
|
||||
|
||||
const rescan = async () => {
|
||||
setRescanning(true);
|
||||
try { const d = await api.post<DetailData>(`/api/subtitles/${id}/rescan`); setData(d); }
|
||||
finally { setRescanning(false); }
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
||||
if (!data) return <Alert variant="error">Item not found.</Alert>;
|
||||
|
||||
const { item, subtitleStreams, files, decisions, subs_extracted, extractCommand, dockerCommand, dockerMountDir } = data;
|
||||
const hasContainerSubs = subtitleStreams.length > 0;
|
||||
const editable = !subs_extracted && hasContainerSubs;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h1 className="text-xl font-bold m-0">
|
||||
<Link to="/review/subtitles" className="font-normal mr-2 no-underline text-gray-500 hover:text-gray-700">← Subtitles</Link>
|
||||
{item.name}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 rounded-lg p-4 mb-3">
|
||||
{/* Meta */}
|
||||
<dl className="flex flex-wrap gap-5 mb-3 text-[0.82rem]">
|
||||
{[
|
||||
{ label: 'Type', value: item.type },
|
||||
...(item.series_name ? [{ label: 'Series', value: item.series_name }] : []),
|
||||
...(item.year ? [{ label: 'Year', value: String(item.year) }] : []),
|
||||
{ label: 'Container', value: item.container ?? '—' },
|
||||
{ label: 'File size', value: item.file_size ? formatBytes(item.file_size) : '—' },
|
||||
{ label: 'Status', value: <Badge variant={subs_extracted ? 'done' : 'pending'}>{subs_extracted ? 'extracted' : 'pending'}</Badge> },
|
||||
].map((entry, i) => (
|
||||
<div key={i}>
|
||||
<dt className="text-gray-500 text-[0.68rem] uppercase tracking-[0.05em] mb-0.5">{entry.label}</dt>
|
||||
<dd className="m-0 font-medium">{entry.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="font-mono text-gray-400 text-[0.78rem] mb-4 break-all">{item.file_path}</div>
|
||||
|
||||
{/* Stream table */}
|
||||
{hasContainerSubs ? (
|
||||
<StreamTable
|
||||
streams={subtitleStreams}
|
||||
decisions={decisions}
|
||||
editable={editable}
|
||||
onLanguageChange={changeLanguage}
|
||||
onTitleChange={changeTitle}
|
||||
/>
|
||||
) : (
|
||||
<Alert variant="warning" className="mb-4">No subtitle streams found in this container.</Alert>
|
||||
)}
|
||||
|
||||
{/* Extracted files */}
|
||||
{files.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h2 className="text-sm font-semibold mb-2">Extracted Sidecar Files</h2>
|
||||
<ExtractedFilesTable files={files} onDelete={deleteFile} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FFmpeg commands */}
|
||||
{extractCommand && (
|
||||
<div className="mt-6">
|
||||
<div className="text-gray-400 text-[0.75rem] uppercase tracking-[0.05em] mb-1">Extraction command</div>
|
||||
<textarea
|
||||
readOnly
|
||||
rows={3}
|
||||
value={extractCommand}
|
||||
className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#9cdcfe] p-3 rounded w-full resize-y border-0 min-h-10"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dockerCommand && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-baseline gap-2 mb-1 flex-wrap">
|
||||
<span className="text-gray-400 text-[0.75rem] uppercase tracking-[0.05em]">Docker (fallback)</span>
|
||||
<span className="text-gray-400 text-[0.7rem]">— mount: <code className="font-mono">{dockerMountDir}:/work</code></span>
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
rows={3}
|
||||
value={dockerCommand}
|
||||
className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#9cdcfe] p-3 rounded w-full resize-y border-0 min-h-10"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{hasContainerSubs && !subs_extracted && (
|
||||
<div className="flex gap-2 mt-6">
|
||||
<Button onClick={extract} disabled={extracting}>
|
||||
{extracting ? 'Queuing…' : '✓ Extract All'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subs_extracted ? (
|
||||
<Alert variant="success" className="mt-4">Subtitles have been extracted to sidecar files.</Alert>
|
||||
) : null}
|
||||
|
||||
{/* Refresh */}
|
||||
<div className="flex items-center gap-3 mt-6 pt-3 border-t border-gray-200">
|
||||
<Button variant="secondary" size="sm" onClick={rescan} disabled={rescanning}>
|
||||
{rescanning ? '↻ Refreshing…' : '↻ Refresh from Jellyfin'}
|
||||
</Button>
|
||||
<span className="text-gray-400 text-[0.75rem]">
|
||||
{rescanning ? 'Triggering Jellyfin metadata probe and waiting for completion…' : 'Triggers a metadata re-probe in Jellyfin, then re-fetches stream data'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/features/subtitles/SubtitleListPage.tsx
Normal file
106
src/features/subtitles/SubtitleListPage.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
|
||||
import { api } from '~/shared/lib/api';
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
import { langName } from '~/shared/lib/lang';
|
||||
import type { MediaItem } from '~/shared/lib/types';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SubtitleListItem extends Pick<MediaItem, 'id' | 'jellyfin_id' | 'type' | 'name' | 'series_name' | 'season_number' | 'episode_number' | 'year' | 'original_language' | 'file_path'> {
|
||||
subs_extracted: number | null;
|
||||
sub_count: number;
|
||||
file_count: number;
|
||||
}
|
||||
|
||||
interface SubtitleListData {
|
||||
items: SubtitleListItem[];
|
||||
filter: string;
|
||||
totalCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
const FILTER_TABS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'not_extracted', label: 'Not Extracted' },
|
||||
{ key: 'extracted', label: 'Extracted' },
|
||||
{ key: 'no_subs', label: 'No Subtitles' },
|
||||
];
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function SubtitleListPage() {
|
||||
const { filter } = useSearch({ from: '/review/subtitles/' });
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<SubtitleListData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<SubtitleListData>(`/api/subtitles?filter=${filter}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
|
||||
}, [filter]);
|
||||
|
||||
if (loading) return <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
||||
if (!data) return <div className="text-red-600">Failed to load.</div>;
|
||||
|
||||
const { items, totalCounts } = data;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-bold m-0 mb-4">Subtitle Manager</h1>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex gap-1 flex-wrap mb-3 items-center">
|
||||
{FILTER_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => navigate({ to: '/review/subtitles', search: { filter: tab.key } as never })}
|
||||
className={`px-2.5 py-0.5 rounded text-[0.8rem] border cursor-pointer transition-colors leading-[1.4] ${filter === tab.key ? 'bg-blue-600 border-blue-600 text-white' : 'border-gray-200 bg-transparent text-gray-500 hover:bg-gray-50'}`}
|
||||
>
|
||||
{tab.label}
|
||||
{totalCounts[tab.key] != null && <> <span className="text-[0.72rem] font-bold">{totalCounts[tab.key]}</span></>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{items.length === 0 && (
|
||||
<p className="text-gray-500">No items match this filter.</p>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<table className="w-full border-collapse text-[0.82rem]">
|
||||
<thead>
|
||||
<tr>
|
||||
{['Name', 'Lang', 'Container Subs', 'Extracted Files', 'Status'].map((h) => (
|
||||
<th key={h} className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => {
|
||||
const status = item.sub_count === 0 ? 'no_subs' : item.subs_extracted ? 'extracted' : 'not_extracted';
|
||||
return (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 align-middle">
|
||||
<Link to="/review/subtitles/$id" params={{ id: String(item.id) }} className="no-underline text-blue-600 hover:text-blue-800">
|
||||
<span className="truncate inline-block max-w-[360px]" title={item.name}>{item.name}</span>
|
||||
</Link>
|
||||
{item.series_name && <span className="text-gray-400 text-[0.72rem]"> — {item.series_name}</span>}
|
||||
{item.year && <span className="text-gray-400 text-[0.72rem]"> ({item.year})</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 align-middle">{langName(item.original_language)}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 align-middle font-mono text-xs">{item.sub_count}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 align-middle font-mono text-xs">{item.file_count}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 align-middle">
|
||||
{status === 'extracted' && <Badge variant="keep">extracted</Badge>}
|
||||
{status === 'not_extracted' && <Badge variant="pending">pending</Badge>}
|
||||
{status === 'no_subs' && <Badge variant="noop">no subs</Badge>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
src/index.css
Normal file
6
src/index.css
Normal file
@@ -0,0 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; }
|
||||
}
|
||||
20
src/main.tsx
Normal file
20
src/main.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import './index.css';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider, createRouter } from '@tanstack/react-router';
|
||||
import { routeTree } from './routeTree.gen';
|
||||
|
||||
const router = createRouter({ routeTree, defaultPreload: 'intent' });
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register { router: typeof router; }
|
||||
}
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (!root) throw new Error('No #root element found');
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
);
|
||||
48
src/routes/__root.tsx
Normal file
48
src/routes/__root.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { createRootRoute, Link, Outlet } from '@tanstack/react-router';
|
||||
import { cn } from '~/shared/lib/utils';
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
});
|
||||
|
||||
function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={cn('px-2.5 py-1 rounded text-[0.85rem] no-underline transition-colors text-gray-500 hover:bg-gray-100 hover:text-gray-900')}
|
||||
activeProps={{ className: 'bg-gray-100 text-gray-900 font-medium' }}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function RootLayout() {
|
||||
const isDev = import.meta.env.DEV;
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-gray-900 text-sm">
|
||||
{isDev && (
|
||||
<div className="bg-amber-100 border-b border-amber-300 text-amber-800 text-xs font-semibold text-center py-1 px-4">
|
||||
⚠ DEV MODE — fresh test database, 50 movies + 10 series per scan
|
||||
</div>
|
||||
)}
|
||||
<nav className="flex items-center gap-0.5 px-5 h-12 bg-white border-b border-gray-200 sticky top-0 z-50">
|
||||
<Link to="/" className="font-bold text-[0.95rem] mr-5 no-underline text-gray-900">
|
||||
🎬 netfelix-audio-fix
|
||||
</Link>
|
||||
<NavLink to="/scan">Scan</NavLink>
|
||||
<NavLink to="/review/audio">Audio</NavLink>
|
||||
<NavLink to="/review/subtitles">Subtitles</NavLink>
|
||||
<NavLink to="/execute">Execute</NavLink>
|
||||
<div className="flex-1" />
|
||||
<NavLink to="/nodes">Nodes</NavLink>
|
||||
<NavLink to="/setup">Settings</NavLink>
|
||||
</nav>
|
||||
<main className="max-w-[1600px] mx-auto px-5 pt-4 pb-12">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import type React from 'react';
|
||||
6
src/routes/execute.tsx
Normal file
6
src/routes/execute.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { ExecutePage } from '~/features/execute/ExecutePage';
|
||||
|
||||
export const Route = createFileRoute('/execute')({
|
||||
component: ExecutePage,
|
||||
});
|
||||
6
src/routes/index.tsx
Normal file
6
src/routes/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { DashboardPage } from '~/features/dashboard/DashboardPage';
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: DashboardPage,
|
||||
});
|
||||
6
src/routes/nodes.tsx
Normal file
6
src/routes/nodes.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { NodesPage } from '~/features/nodes/NodesPage';
|
||||
|
||||
export const Route = createFileRoute('/nodes')({
|
||||
component: NodesPage,
|
||||
});
|
||||
5
src/routes/review.tsx
Normal file
5
src/routes/review.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/review')({
|
||||
component: () => <Outlet />,
|
||||
});
|
||||
6
src/routes/review/audio/$id.tsx
Normal file
6
src/routes/review/audio/$id.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { AudioDetailPage } from '~/features/review/AudioDetailPage';
|
||||
|
||||
export const Route = createFileRoute('/review/audio/$id')({
|
||||
component: AudioDetailPage,
|
||||
});
|
||||
10
src/routes/review/audio/index.tsx
Normal file
10
src/routes/review/audio/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
import { AudioListPage } from '~/features/review/AudioListPage';
|
||||
|
||||
export const Route = createFileRoute('/review/audio/')({
|
||||
validateSearch: z.object({
|
||||
filter: z.enum(['all', 'needs_action', 'noop', 'manual', 'approved', 'skipped', 'done', 'error']).default('all'),
|
||||
}),
|
||||
component: AudioListPage,
|
||||
});
|
||||
5
src/routes/review/index.tsx
Normal file
5
src/routes/review/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/review/')({
|
||||
beforeLoad: () => { throw redirect({ to: '/review/audio' }); },
|
||||
});
|
||||
6
src/routes/review/subtitles/$id.tsx
Normal file
6
src/routes/review/subtitles/$id.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { SubtitleDetailPage } from '~/features/subtitles/SubtitleDetailPage';
|
||||
|
||||
export const Route = createFileRoute('/review/subtitles/$id')({
|
||||
component: SubtitleDetailPage,
|
||||
});
|
||||
10
src/routes/review/subtitles/index.tsx
Normal file
10
src/routes/review/subtitles/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
import { SubtitleListPage } from '~/features/subtitles/SubtitleListPage';
|
||||
|
||||
export const Route = createFileRoute('/review/subtitles/')({
|
||||
validateSearch: z.object({
|
||||
filter: z.enum(['all', 'not_extracted', 'extracted', 'no_subs']).default('all'),
|
||||
}),
|
||||
component: SubtitleListPage,
|
||||
});
|
||||
6
src/routes/scan.tsx
Normal file
6
src/routes/scan.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { ScanPage } from '~/features/scan/ScanPage';
|
||||
|
||||
export const Route = createFileRoute('/scan')({
|
||||
component: ScanPage,
|
||||
});
|
||||
6
src/routes/setup.tsx
Normal file
6
src/routes/setup.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { SetupPage } from '~/features/setup/SetupPage';
|
||||
|
||||
export const Route = createFileRoute('/setup')({
|
||||
component: SetupPage,
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Hono } from 'hono';
|
||||
import { serveStatic } from 'hono/bun';
|
||||
import { getDb, getConfig, getAllConfig } from './db/index';
|
||||
import type { MediaItem } from './types';
|
||||
import { DashboardPage } from './views/dashboard';
|
||||
|
||||
import setupRoutes from './api/setup';
|
||||
import scanRoutes from './api/scan';
|
||||
import reviewRoutes from './api/review';
|
||||
import executeRoutes from './api/execute';
|
||||
import nodesRoutes from './api/nodes';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// ─── Static assets ────────────────────────────────────────────────────────────
|
||||
|
||||
app.use('/app.css', serveStatic({ path: './public/app.css' }));
|
||||
|
||||
// ─── Setup guard ──────────────────────────────────────────────────────────────
|
||||
|
||||
app.use('*', async (c, next) => {
|
||||
const path = new URL(c.req.url).pathname;
|
||||
// Allow setup routes, static assets, and SSE endpoints without setup check
|
||||
if (
|
||||
path.startsWith('/setup') ||
|
||||
path === '/app.css' ||
|
||||
path.startsWith('/scan/events') ||
|
||||
path.startsWith('/execute/events')
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const setupComplete = getConfig('setup_complete') === '1';
|
||||
if (!setupComplete) {
|
||||
return c.redirect('/setup');
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
// ─── Dashboard ────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/', (c) => {
|
||||
const db = getDb();
|
||||
|
||||
const totalItems = (db.prepare('SELECT COUNT(*) as n FROM media_items').get() as { n: number }).n;
|
||||
const scanned = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'scanned'").get() as { n: number }).n;
|
||||
const needsAction = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }).n;
|
||||
const noChange = (db.prepare('SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1').get() as { n: number }).n;
|
||||
const approved = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'approved'").get() as { n: number }).n;
|
||||
const done = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'done'").get() as { n: number }).n;
|
||||
const errors = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'error'").get() as { n: number }).n;
|
||||
const scanRunning = getConfig('scan_running') === '1';
|
||||
|
||||
return c.html(
|
||||
<DashboardPage
|
||||
stats={{ totalItems, scanned, needsAction, approved, done, errors, noChange }}
|
||||
scanRunning={scanRunning}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.route('/setup', setupRoutes);
|
||||
app.route('/scan', scanRoutes);
|
||||
app.route('/review', reviewRoutes);
|
||||
app.route('/execute', executeRoutes);
|
||||
app.route('/nodes', nodesRoutes);
|
||||
|
||||
// ─── Start server ─────────────────────────────────────────────────────────────
|
||||
|
||||
const port = Number(process.env.PORT ?? '3000');
|
||||
|
||||
console.log(`netfelix-audio-fix starting on http://localhost:${port}`);
|
||||
|
||||
// Initialize DB on startup
|
||||
getDb();
|
||||
|
||||
export default {
|
||||
port,
|
||||
fetch: app.fetch,
|
||||
};
|
||||
@@ -1,186 +0,0 @@
|
||||
import type { MediaItem, MediaStream, PlanResult } from '../types';
|
||||
import { normalizeLanguage } from './jellyfin';
|
||||
|
||||
export interface AnalyzerConfig {
|
||||
subtitleLanguages: string[]; // ISO 639-2 codes to keep
|
||||
}
|
||||
|
||||
const DEFAULT_SUBTITLE_ORDER: Record<string, number> = {
|
||||
eng: 0,
|
||||
deu: 1,
|
||||
spa: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Given an item and its streams, compute what action to take for each stream
|
||||
* and whether the file needs remuxing at all.
|
||||
*/
|
||||
export function analyzeItem(
|
||||
item: Pick<MediaItem, 'original_language' | 'needs_review'>,
|
||||
streams: MediaStream[],
|
||||
config: AnalyzerConfig
|
||||
): PlanResult {
|
||||
const origLang = item.original_language ? normalizeLanguage(item.original_language) : null;
|
||||
const keepSubLangs = new Set(config.subtitleLanguages.map(normalizeLanguage));
|
||||
const notes: string[] = [];
|
||||
|
||||
// Compute action for each stream
|
||||
const decisions: PlanResult['decisions'] = streams.map((s) => {
|
||||
const action = decideAction(s, origLang, keepSubLangs);
|
||||
return { stream_id: s.id, action, target_index: null };
|
||||
});
|
||||
|
||||
// Check if any stream is being removed
|
||||
const anyRemoved = decisions.some((d) => d.action === 'remove');
|
||||
|
||||
// Compute target ordering for kept streams within type groups
|
||||
const keptStreams = streams.filter((_, i) => decisions[i].action === 'keep');
|
||||
assignTargetOrder(keptStreams, decisions, streams, origLang);
|
||||
|
||||
// Check if ordering changes (compare current order vs target order of kept streams)
|
||||
const orderChanged = checkOrderChanged(streams, decisions);
|
||||
|
||||
const isNoop = !anyRemoved && !orderChanged;
|
||||
|
||||
// Generate notes for edge cases
|
||||
if (!origLang && item.needs_review) {
|
||||
notes.push('Original language unknown — audio tracks not filtered; manual review required');
|
||||
}
|
||||
|
||||
const mp4SubIssue = checkMp4SubtitleIssue(item as MediaItem, streams, decisions);
|
||||
if (mp4SubIssue) notes.push(mp4SubIssue);
|
||||
|
||||
return {
|
||||
is_noop: isNoop,
|
||||
decisions,
|
||||
notes: notes.length > 0 ? notes.join('\n') : null,
|
||||
};
|
||||
}
|
||||
|
||||
function decideAction(
|
||||
stream: MediaStream,
|
||||
origLang: string | null,
|
||||
keepSubLangs: Set<string>
|
||||
): 'keep' | 'remove' {
|
||||
switch (stream.type) {
|
||||
case 'Video':
|
||||
case 'Data':
|
||||
case 'EmbeddedImage':
|
||||
return 'keep';
|
||||
|
||||
case 'Audio': {
|
||||
if (!origLang) return 'keep'; // unknown lang → keep all
|
||||
if (!stream.language) return 'keep'; // undetermined → keep
|
||||
return normalizeLanguage(stream.language) === origLang ? 'keep' : 'remove';
|
||||
}
|
||||
|
||||
case 'Subtitle': {
|
||||
if (stream.is_forced) return 'keep';
|
||||
if (stream.is_hearing_impaired) return 'keep';
|
||||
if (!stream.language) return 'remove'; // undetermined subtitle → remove
|
||||
return keepSubLangs.has(normalizeLanguage(stream.language)) ? 'keep' : 'remove';
|
||||
}
|
||||
|
||||
default:
|
||||
return 'keep';
|
||||
}
|
||||
}
|
||||
|
||||
function assignTargetOrder(
|
||||
keptStreams: MediaStream[],
|
||||
decisions: PlanResult['decisions'],
|
||||
allStreams: MediaStream[],
|
||||
origLang: string | null
|
||||
): void {
|
||||
// Group kept streams by type
|
||||
const byType: Record<string, MediaStream[]> = {};
|
||||
for (const s of keptStreams) {
|
||||
const t = s.type;
|
||||
byType[t] = byType[t] ?? [];
|
||||
byType[t].push(s);
|
||||
}
|
||||
|
||||
// Sort audio: original lang first, then by stream_index
|
||||
if (byType['Audio']) {
|
||||
byType['Audio'].sort((a, b) => {
|
||||
const aIsOrig = origLang && a.language && normalizeLanguage(a.language) === origLang ? 0 : 1;
|
||||
const bIsOrig = origLang && b.language && normalizeLanguage(b.language) === origLang ? 0 : 1;
|
||||
if (aIsOrig !== bIsOrig) return aIsOrig - bIsOrig;
|
||||
return a.stream_index - b.stream_index;
|
||||
});
|
||||
}
|
||||
|
||||
// Sort subtitles: eng → deu → spa → forced → CC → rest
|
||||
if (byType['Subtitle']) {
|
||||
byType['Subtitle'].sort((a, b) => {
|
||||
const aOrder = subtitleSortKey(a);
|
||||
const bOrder = subtitleSortKey(b);
|
||||
if (aOrder !== bOrder) return aOrder - bOrder;
|
||||
return a.stream_index - b.stream_index;
|
||||
});
|
||||
}
|
||||
|
||||
// Assign target_index per type group
|
||||
for (const [, typeStreams] of Object.entries(byType)) {
|
||||
typeStreams.forEach((s, idx) => {
|
||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||
if (dec) dec.target_index = idx;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function subtitleSortKey(s: MediaStream): number {
|
||||
if (s.is_forced) return 90;
|
||||
if (s.is_hearing_impaired) return 95;
|
||||
if (!s.language) return 99;
|
||||
const lang = normalizeLanguage(s.language);
|
||||
return DEFAULT_SUBTITLE_ORDER[lang] ?? 50;
|
||||
}
|
||||
|
||||
function checkOrderChanged(
|
||||
streams: MediaStream[],
|
||||
decisions: PlanResult['decisions']
|
||||
): boolean {
|
||||
// Build ordered list of kept streams by their target_index within each type group
|
||||
// Compare against their current stream_index positions
|
||||
const kept = streams.filter((s) => {
|
||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||
return dec?.action === 'keep';
|
||||
});
|
||||
|
||||
// Per type, check if target_index matches current relative position
|
||||
const byType: Record<string, MediaStream[]> = {};
|
||||
for (const s of kept) {
|
||||
byType[s.type] = byType[s.type] ?? [];
|
||||
byType[s.type].push(s);
|
||||
}
|
||||
|
||||
for (const typeStreams of Object.values(byType)) {
|
||||
const sorted = [...typeStreams].sort((a, b) => a.stream_index - b.stream_index);
|
||||
for (let i = 0; i < typeStreams.length; i++) {
|
||||
const dec = decisions.find((d) => d.stream_id === typeStreams[i].id);
|
||||
if (!dec) continue;
|
||||
// Check if target_index matches position in sorted list
|
||||
const currentPos = sorted.findIndex((s) => s.id === typeStreams[i].id);
|
||||
if (dec.target_index !== null && dec.target_index !== currentPos) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function checkMp4SubtitleIssue(
|
||||
item: MediaItem,
|
||||
streams: MediaStream[],
|
||||
decisions: PlanResult['decisions']
|
||||
): string | null {
|
||||
if (!item.container || item.container.toLowerCase() !== 'mp4') return null;
|
||||
const incompatibleCodecs = new Set(['hdmv_pgs_subtitle', 'pgssub', 'dvd_subtitle', 'ass', 'ssa']);
|
||||
const keptSubtitles = streams.filter((s) => {
|
||||
if (s.type !== 'Subtitle') return false;
|
||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||
return dec?.action === 'keep';
|
||||
});
|
||||
const bad = keptSubtitles.filter((s) => s.codec && incompatibleCodecs.has(s.codec.toLowerCase()));
|
||||
if (bad.length === 0) return null;
|
||||
return `MP4 container with incompatible subtitle codec(s): ${bad.map((s) => s.codec).join(', ')} — consider converting to MKV`;
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import type { MediaItem, MediaStream, StreamDecision } from '../types';
|
||||
import { normalizeLanguage } from './jellyfin';
|
||||
|
||||
/**
|
||||
* Build the full shell command to remux a media file, keeping only the
|
||||
* streams specified by the decisions and in the target order.
|
||||
*
|
||||
* Returns null if all streams are kept and ordering is unchanged (noop).
|
||||
*/
|
||||
export function buildCommand(
|
||||
item: MediaItem,
|
||||
streams: MediaStream[],
|
||||
decisions: StreamDecision[]
|
||||
): string {
|
||||
// Sort kept streams by type priority then target_index
|
||||
const kept = streams
|
||||
.map((s) => {
|
||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||
return dec?.action === 'keep' ? { stream: s, dec } : null;
|
||||
})
|
||||
.filter(Boolean) as { stream: MediaStream; dec: StreamDecision }[];
|
||||
|
||||
// Sort: Video first, Audio second, Subtitle third, Data last
|
||||
const typeOrder: Record<string, number> = { Video: 0, Audio: 1, Subtitle: 2, Data: 3, EmbeddedImage: 4 };
|
||||
kept.sort((a, b) => {
|
||||
const ta = typeOrder[a.stream.type] ?? 9;
|
||||
const tb = typeOrder[b.stream.type] ?? 9;
|
||||
if (ta !== tb) return ta - tb;
|
||||
return (a.dec.target_index ?? 0) - (b.dec.target_index ?? 0);
|
||||
});
|
||||
|
||||
const inputPath = item.file_path;
|
||||
const ext = inputPath.match(/\.([^.]+)$/)?.[1] ?? 'mkv';
|
||||
const tmpPath = inputPath.replace(/\.[^.]+$/, `.tmp.${ext}`);
|
||||
|
||||
const maps = kept.map((k) => `-map 0:${k.stream.stream_index}`);
|
||||
|
||||
const parts: string[] = [
|
||||
'ffmpeg',
|
||||
'-y',
|
||||
'-i', shellQuote(inputPath),
|
||||
...maps,
|
||||
'-c copy',
|
||||
shellQuote(tmpPath),
|
||||
'&&',
|
||||
'mv', shellQuote(tmpPath), shellQuote(inputPath),
|
||||
];
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a command that also changes the container to MKV.
|
||||
* Used when MP4 container can't hold certain subtitle codecs.
|
||||
*/
|
||||
export function buildMkvConvertCommand(
|
||||
item: MediaItem,
|
||||
streams: MediaStream[],
|
||||
decisions: StreamDecision[]
|
||||
): string {
|
||||
const inputPath = item.file_path;
|
||||
const outputPath = inputPath.replace(/\.[^.]+$/, '.mkv');
|
||||
const tmpPath = inputPath.replace(/\.[^.]+$/, '.tmp.mkv');
|
||||
|
||||
const kept = streams
|
||||
.map((s) => {
|
||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||
return dec?.action === 'keep' ? { stream: s, dec } : null;
|
||||
})
|
||||
.filter(Boolean) as { stream: MediaStream; dec: StreamDecision }[];
|
||||
|
||||
const typeOrder: Record<string, number> = { Video: 0, Audio: 1, Subtitle: 2, Data: 3 };
|
||||
kept.sort((a, b) => {
|
||||
const ta = typeOrder[a.stream.type] ?? 9;
|
||||
const tb = typeOrder[b.stream.type] ?? 9;
|
||||
if (ta !== tb) return ta - tb;
|
||||
return (a.dec.target_index ?? 0) - (b.dec.target_index ?? 0);
|
||||
});
|
||||
|
||||
const maps = kept.map((k) => `-map 0:${k.stream.stream_index}`);
|
||||
|
||||
return [
|
||||
'ffmpeg', '-y',
|
||||
'-i', shellQuote(inputPath),
|
||||
...maps,
|
||||
'-c copy',
|
||||
'-f matroska',
|
||||
shellQuote(tmpPath),
|
||||
'&&',
|
||||
'mv', shellQuote(tmpPath), shellQuote(outputPath),
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/** Safely quote a path for shell usage. */
|
||||
export function shellQuote(s: string): string {
|
||||
return `'${s.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
/** Returns a human-readable summary of what will change. */
|
||||
export function summarizeChanges(
|
||||
streams: MediaStream[],
|
||||
decisions: StreamDecision[]
|
||||
): { removed: MediaStream[]; kept: MediaStream[] } {
|
||||
const removed: MediaStream[] = [];
|
||||
const kept: MediaStream[] = [];
|
||||
for (const s of streams) {
|
||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||
if (!dec || dec.action === 'remove') removed.push(s);
|
||||
else kept.push(s);
|
||||
}
|
||||
return { removed, kept };
|
||||
}
|
||||
|
||||
/** Format a stream for display. */
|
||||
export function streamLabel(s: MediaStream): string {
|
||||
const parts: string[] = [s.type];
|
||||
if (s.codec) parts.push(s.codec);
|
||||
if (s.language_display || s.language) parts.push(s.language_display ?? s.language!);
|
||||
if (s.title) parts.push(`"${s.title}"`);
|
||||
if (s.type === 'Audio' && s.channels) parts.push(`${s.channels}ch`);
|
||||
if (s.is_forced) parts.push('forced');
|
||||
if (s.is_hearing_impaired) parts.push('CC');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import type { JellyfinItem, JellyfinMediaStream, JellyfinUser, MediaStream } from '../types';
|
||||
|
||||
export interface JellyfinConfig {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
function headers(apiKey: string): Record<string, string> {
|
||||
return {
|
||||
'X-Emby-Token': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
export async function testConnection(cfg: JellyfinConfig): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const res = await fetch(`${cfg.url}/Users`, {
|
||||
headers: headers(cfg.apiKey),
|
||||
});
|
||||
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUsers(cfg: Pick<JellyfinConfig, 'url' | 'apiKey'>): Promise<JellyfinUser[]> {
|
||||
const res = await fetch(`${cfg.url}/Users`, { headers: headers(cfg.apiKey) });
|
||||
if (!res.ok) throw new Error(`Jellyfin /Users failed: ${res.status}`);
|
||||
return res.json() as Promise<JellyfinUser[]>;
|
||||
}
|
||||
|
||||
export async function* getAllItems(
|
||||
cfg: JellyfinConfig,
|
||||
onProgress?: (count: number, total: number) => void
|
||||
): AsyncGenerator<JellyfinItem> {
|
||||
const fields = [
|
||||
'MediaStreams',
|
||||
'Path',
|
||||
'ProviderIds',
|
||||
'OriginalTitle',
|
||||
'ProductionYear',
|
||||
'Size',
|
||||
'Container',
|
||||
].join(',');
|
||||
|
||||
let startIndex = 0;
|
||||
let total = 0;
|
||||
|
||||
do {
|
||||
const url = new URL(`${cfg.url}/Users/${cfg.userId}/Items`);
|
||||
url.searchParams.set('Recursive', 'true');
|
||||
url.searchParams.set('IncludeItemTypes', 'Movie,Episode');
|
||||
url.searchParams.set('Fields', fields);
|
||||
url.searchParams.set('Limit', String(PAGE_SIZE));
|
||||
url.searchParams.set('StartIndex', String(startIndex));
|
||||
|
||||
const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) });
|
||||
if (!res.ok) throw new Error(`Jellyfin items failed: ${res.status}`);
|
||||
|
||||
const body = (await res.json()) as { Items: JellyfinItem[]; TotalRecordCount: number };
|
||||
total = body.TotalRecordCount;
|
||||
|
||||
for (const item of body.Items) {
|
||||
yield item;
|
||||
}
|
||||
|
||||
startIndex += body.Items.length;
|
||||
onProgress?.(startIndex, total);
|
||||
} while (startIndex < total);
|
||||
}
|
||||
|
||||
/** Map a Jellyfin item to our normalized language code (ISO 639-2). */
|
||||
export function extractOriginalLanguage(item: JellyfinItem): string | null {
|
||||
// Jellyfin doesn't have a direct "original_language" field like TMDb.
|
||||
// The best proxy is the language of the first audio stream.
|
||||
if (!item.MediaStreams) return null;
|
||||
const firstAudio = item.MediaStreams.find((s) => s.Type === 'Audio');
|
||||
return firstAudio?.Language ? normalizeLanguage(firstAudio.Language) : null;
|
||||
}
|
||||
|
||||
/** Map a Jellyfin MediaStream to our internal MediaStream shape (sans id/item_id). */
|
||||
export function mapStream(s: JellyfinMediaStream): Omit<MediaStream, 'id' | 'item_id'> {
|
||||
return {
|
||||
stream_index: s.Index,
|
||||
type: s.Type as MediaStream['type'],
|
||||
codec: s.Codec ?? null,
|
||||
language: s.Language ? normalizeLanguage(s.Language) : null,
|
||||
language_display: s.DisplayLanguage ?? null,
|
||||
title: s.Title ?? null,
|
||||
is_default: s.IsDefault ? 1 : 0,
|
||||
is_forced: s.IsForced ? 1 : 0,
|
||||
is_hearing_impaired: s.IsHearingImpaired ? 1 : 0,
|
||||
channels: s.Channels ?? null,
|
||||
channel_layout: s.ChannelLayout ?? null,
|
||||
bit_rate: s.BitRate ?? null,
|
||||
sample_rate: s.SampleRate ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ISO 639-2/T → ISO 639-2/B normalization + common aliases
|
||||
const LANG_ALIASES: Record<string, string> = {
|
||||
// German: both /T (deu) and /B (ger) → deu
|
||||
ger: 'deu',
|
||||
// Chinese
|
||||
chi: 'zho',
|
||||
// French
|
||||
fre: 'fra',
|
||||
// Dutch
|
||||
dut: 'nld',
|
||||
// Modern Greek
|
||||
gre: 'ell',
|
||||
// Hebrew
|
||||
heb: 'heb',
|
||||
// Farsi
|
||||
per: 'fas',
|
||||
// Romanian
|
||||
rum: 'ron',
|
||||
// Malay
|
||||
may: 'msa',
|
||||
// Tibetan
|
||||
tib: 'bod',
|
||||
// Burmese
|
||||
bur: 'mya',
|
||||
// Czech
|
||||
cze: 'ces',
|
||||
// Slovak
|
||||
slo: 'slk',
|
||||
// Georgian
|
||||
geo: 'kat',
|
||||
// Icelandic
|
||||
ice: 'isl',
|
||||
// Armenian
|
||||
arm: 'hye',
|
||||
// Basque
|
||||
baq: 'eus',
|
||||
// Albanian
|
||||
alb: 'sqi',
|
||||
// Macedonian
|
||||
mac: 'mkd',
|
||||
// Welsh
|
||||
wel: 'cym',
|
||||
};
|
||||
|
||||
export function normalizeLanguage(lang: string): string {
|
||||
const lower = lang.toLowerCase().trim();
|
||||
return LANG_ALIASES[lower] ?? lower;
|
||||
}
|
||||
21
src/shared/components/ui/alert.tsx
Normal file
21
src/shared/components/ui/alert.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type React from 'react';
|
||||
import { cn } from '~/shared/lib/utils';
|
||||
|
||||
const variants = {
|
||||
info: 'bg-cyan-50 text-cyan-800 border border-cyan-200',
|
||||
warning: 'bg-amber-50 text-amber-800 border border-amber-200',
|
||||
error: 'bg-red-50 text-red-800 border border-red-200',
|
||||
success: 'bg-green-50 text-green-800 border border-green-200',
|
||||
} as const;
|
||||
|
||||
interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: keyof typeof variants;
|
||||
}
|
||||
|
||||
export function Alert({ variant = 'info', className, children, ...props }: AlertProps) {
|
||||
return (
|
||||
<div className={cn('p-3 rounded text-sm', variants[variant], className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
src/shared/components/ui/badge.tsx
Normal file
36
src/shared/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { cn } from '~/shared/lib/utils';
|
||||
|
||||
const variants = {
|
||||
default: 'bg-gray-100 text-gray-600',
|
||||
keep: 'bg-green-100 text-green-800',
|
||||
remove: 'bg-red-100 text-red-800',
|
||||
pending: 'bg-gray-200 text-gray-600',
|
||||
approved: 'bg-green-100 text-green-800',
|
||||
skipped: 'bg-gray-200 text-gray-600',
|
||||
done: 'bg-cyan-100 text-cyan-800',
|
||||
error: 'bg-red-100 text-red-800',
|
||||
noop: 'bg-gray-200 text-gray-600',
|
||||
running: 'bg-amber-100 text-amber-800',
|
||||
manual: 'bg-orange-100 text-orange-800',
|
||||
} as const;
|
||||
|
||||
interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: keyof typeof variants;
|
||||
}
|
||||
|
||||
export function Badge({ variant = 'default', className, children, ...props }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block text-[0.67rem] font-semibold px-[0.45em] py-[0.1em] rounded-full uppercase tracking-[0.03em] whitespace-nowrap',
|
||||
variants[variant],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
import type React from 'react';
|
||||
26
src/shared/components/ui/button.tsx
Normal file
26
src/shared/components/ui/button.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type React from 'react';
|
||||
import { cn } from '~/shared/lib/utils';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
size?: 'default' | 'sm' | 'xs';
|
||||
}
|
||||
|
||||
export function Button({ variant = 'primary', size = 'default', className, ...props }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-1 rounded font-medium cursor-pointer transition-colors border-0',
|
||||
variant === 'primary' && 'bg-blue-600 text-white hover:bg-blue-700',
|
||||
variant === 'secondary' && 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50',
|
||||
variant === 'danger' && 'bg-white text-red-600 border border-red-400 hover:bg-red-50',
|
||||
size === 'default' && 'px-3 py-1.5 text-sm',
|
||||
size === 'sm' && 'px-2.5 py-1 text-xs',
|
||||
size === 'xs' && 'px-2 py-0.5 text-xs',
|
||||
props.disabled && 'opacity-50 cursor-not-allowed',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
16
src/shared/components/ui/input.tsx
Normal file
16
src/shared/components/ui/input.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type React from 'react';
|
||||
import { cn } from '~/shared/lib/utils';
|
||||
|
||||
export function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
'border border-gray-300 rounded px-2.5 py-1.5 text-sm bg-white w-full',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
|
||||
'disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
16
src/shared/components/ui/select.tsx
Normal file
16
src/shared/components/ui/select.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type React from 'react';
|
||||
import { cn } from '~/shared/lib/utils';
|
||||
|
||||
export function Select({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
<select
|
||||
className={cn(
|
||||
'border border-gray-300 rounded px-2 py-1.5 text-sm bg-white',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
|
||||
'disabled:bg-gray-100 disabled:cursor-not-allowed',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
15
src/shared/components/ui/textarea.tsx
Normal file
15
src/shared/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type React from 'react';
|
||||
import { cn } from '~/shared/lib/utils';
|
||||
|
||||
export function Textarea({ className, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'border border-gray-300 rounded px-2.5 py-1.5 text-sm bg-white w-full resize-vertical',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
26
src/shared/lib/api.ts
Normal file
26
src/shared/lib/api.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/** Base URL for API calls. In dev Vite proxies /api → :3000. */
|
||||
const BASE = '';
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(BASE + path, {
|
||||
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
|
||||
...init,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined }),
|
||||
patch: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'PATCH', body: body !== undefined ? JSON.stringify(body) : undefined }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
/** POST multipart/form-data (file upload). Omit Content-Type so browser sets boundary. */
|
||||
postForm: <T>(path: string, body: FormData) =>
|
||||
request<T>(path, { method: 'POST', body, headers: {} }),
|
||||
};
|
||||
18
src/shared/lib/lang.ts
Normal file
18
src/shared/lib/lang.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const LANG_NAMES: Record<string, string> = {
|
||||
eng: 'English', deu: 'German', spa: 'Spanish', fra: 'French', ita: 'Italian',
|
||||
por: 'Portuguese', jpn: 'Japanese', kor: 'Korean', zho: 'Chinese', ara: 'Arabic',
|
||||
rus: 'Russian', nld: 'Dutch', swe: 'Swedish', nor: 'Norwegian', dan: 'Danish',
|
||||
fin: 'Finnish', pol: 'Polish', tur: 'Turkish', tha: 'Thai', hin: 'Hindi',
|
||||
hun: 'Hungarian', ces: 'Czech', ron: 'Romanian', ell: 'Greek', heb: 'Hebrew',
|
||||
fas: 'Persian', ukr: 'Ukrainian', ind: 'Indonesian', msa: 'Malay', vie: 'Vietnamese',
|
||||
cat: 'Catalan', tam: 'Tamil', tel: 'Telugu', slk: 'Slovak', hrv: 'Croatian',
|
||||
bul: 'Bulgarian', srp: 'Serbian', slv: 'Slovenian', lav: 'Latvian', lit: 'Lithuanian',
|
||||
est: 'Estonian', isl: 'Icelandic', nob: 'Norwegian Bokmål', nno: 'Norwegian Nynorsk',
|
||||
};
|
||||
|
||||
export const KNOWN_LANG_NAMES = new Set(Object.values(LANG_NAMES).map((n) => n.toLowerCase()));
|
||||
|
||||
export function langName(code: string | null | undefined): string {
|
||||
if (!code) return '—';
|
||||
return LANG_NAMES[code.toLowerCase()] ?? code.toUpperCase();
|
||||
}
|
||||
100
src/shared/lib/types.ts
Normal file
100
src/shared/lib/types.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// Client-side type definitions mirroring server/types.ts
|
||||
|
||||
export interface MediaItem {
|
||||
id: number;
|
||||
jellyfin_id: string;
|
||||
type: 'Movie' | 'Episode';
|
||||
name: string;
|
||||
series_name: string | null;
|
||||
series_jellyfin_id: string | null;
|
||||
season_number: number | null;
|
||||
episode_number: number | null;
|
||||
year: number | null;
|
||||
file_path: string;
|
||||
file_size: number | null;
|
||||
container: string | null;
|
||||
original_language: string | null;
|
||||
orig_lang_source: string | null;
|
||||
needs_review: number;
|
||||
imdb_id: string | null;
|
||||
tmdb_id: string | null;
|
||||
tvdb_id: string | null;
|
||||
scan_status: string;
|
||||
last_scanned_at: string | null;
|
||||
}
|
||||
|
||||
export interface MediaStream {
|
||||
id: number;
|
||||
item_id: number;
|
||||
stream_index: number;
|
||||
type: string;
|
||||
codec: string | null;
|
||||
language: string | null;
|
||||
language_display: string | null;
|
||||
title: string | null;
|
||||
is_default: number;
|
||||
is_forced: number;
|
||||
is_hearing_impaired: number;
|
||||
channels: number | null;
|
||||
channel_layout: string | null;
|
||||
bit_rate: number | null;
|
||||
sample_rate: number | null;
|
||||
}
|
||||
|
||||
export interface ReviewPlan {
|
||||
id: number;
|
||||
item_id: number;
|
||||
status: string;
|
||||
is_noop: number;
|
||||
subs_extracted: number;
|
||||
notes: string | null;
|
||||
reviewed_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SubtitleFile {
|
||||
id: number;
|
||||
item_id: number;
|
||||
file_path: string;
|
||||
language: string | null;
|
||||
codec: string | null;
|
||||
is_forced: number;
|
||||
is_hearing_impaired: number;
|
||||
file_size: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface StreamDecision {
|
||||
id: number;
|
||||
plan_id: number;
|
||||
stream_id: number;
|
||||
action: 'keep' | 'remove';
|
||||
target_index: number | null;
|
||||
custom_title: string | null;
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
id: number;
|
||||
item_id: number;
|
||||
node_id: number | null;
|
||||
command: string;
|
||||
status: 'pending' | 'running' | 'done' | 'error';
|
||||
output: string | null;
|
||||
exit_code: number | null;
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
id: number;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
private_key: string;
|
||||
ffmpeg_path: string;
|
||||
work_dir: string;
|
||||
status: string;
|
||||
last_checked_at: string | null;
|
||||
}
|
||||
6
src/shared/lib/utils.ts
Normal file
6
src/shared/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { FC } from 'hono/jsx';
|
||||
import { Layout } from './layout';
|
||||
|
||||
interface DashboardProps {
|
||||
stats: {
|
||||
totalItems: number;
|
||||
scanned: number;
|
||||
needsAction: number;
|
||||
approved: number;
|
||||
done: number;
|
||||
errors: number;
|
||||
noChange: number;
|
||||
};
|
||||
scanRunning: boolean;
|
||||
}
|
||||
|
||||
export const DashboardPage: FC<DashboardProps> = ({ stats, scanRunning }) => (
|
||||
<Layout title="Dashboard" activeNav="dashboard">
|
||||
<div class="page-header">
|
||||
<h1>Dashboard</h1>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card">
|
||||
<div class="num">{stats.totalItems.toLocaleString()}</div>
|
||||
<div class="label">Total items</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="num">{stats.scanned.toLocaleString()}</div>
|
||||
<div class="label">Scanned</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="num">{stats.needsAction.toLocaleString()}</div>
|
||||
<div class="label">Needs action</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="num">{stats.noChange.toLocaleString()}</div>
|
||||
<div class="label">No change needed</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="num">{stats.approved.toLocaleString()}</div>
|
||||
<div class="label">Approved / queued</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="num">{stats.done.toLocaleString()}</div>
|
||||
<div class="label">Done</div>
|
||||
</div>
|
||||
{stats.errors > 0 && (
|
||||
<div class="stat-card">
|
||||
<div class="num" style="color:var(--color-error)">{stats.errors.toLocaleString()}</div>
|
||||
<div class="label">Errors</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="flex-row" style="gap:0.75rem;margin-bottom:2rem;">
|
||||
{scanRunning ? (
|
||||
<a href="/scan" role="button" class="secondary">⏳ Scan running…</a>
|
||||
) : (
|
||||
<form method="post" action="/scan/start" style="display:inline">
|
||||
<button type="submit">▶ Start Scan</button>
|
||||
</form>
|
||||
)}
|
||||
<a href="/review" role="button" class="secondary">Review changes</a>
|
||||
<a href="/execute" role="button" class="secondary">Execute jobs</a>
|
||||
</div>
|
||||
|
||||
{stats.scanned === 0 && (
|
||||
<div class="alert alert-info">
|
||||
Library not scanned yet. Click <strong>Start Scan</strong> to begin.
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
@@ -1,182 +0,0 @@
|
||||
import type { FC } from 'hono/jsx';
|
||||
import { Layout } from './layout';
|
||||
import type { Job, Node, MediaItem } from '../types';
|
||||
|
||||
interface ExecutePageProps {
|
||||
jobs: Array<{
|
||||
job: Job;
|
||||
item: MediaItem | null;
|
||||
node: Node | null;
|
||||
}>;
|
||||
nodes: Node[];
|
||||
}
|
||||
|
||||
export const ExecutePage: FC<ExecutePageProps> = ({ jobs, nodes }) => {
|
||||
const pending = jobs.filter((j) => j.job.status === 'pending').length;
|
||||
const running = jobs.filter((j) => j.job.status === 'running').length;
|
||||
const done = jobs.filter((j) => j.job.status === 'done').length;
|
||||
const errors = jobs.filter((j) => j.job.status === 'error').length;
|
||||
|
||||
const hasActiveJobs = running > 0;
|
||||
|
||||
return (
|
||||
<Layout title="Execute" activeNav="execute">
|
||||
<div class="page-header">
|
||||
<h1>Execute Jobs</h1>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div class="flex-row" style="gap:0.75rem;margin-bottom:1.5rem;flex-wrap:wrap;">
|
||||
<span class="badge badge-pending">{pending} pending</span>
|
||||
{running > 0 && <span class="badge badge-running">{running} running</span>}
|
||||
{done > 0 && <span class="badge badge-done">{done} done</span>}
|
||||
{errors > 0 && <span class="badge badge-error">{errors} error(s)</span>}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div class="flex-row" style="margin-bottom:1.5rem;gap:0.75rem;">
|
||||
{pending > 0 && (
|
||||
<form method="post" action="/execute/start" style="display:inline">
|
||||
<button type="submit">▶ Run all pending</button>
|
||||
</form>
|
||||
)}
|
||||
{jobs.length === 0 && (
|
||||
<p class="muted">No jobs yet. Go to <a href="/review">Review</a> and approve items first.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Job table */}
|
||||
{jobs.length > 0 && (
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Item</th>
|
||||
<th>Command</th>
|
||||
<th>Node</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map(({ job, item, node }) => (
|
||||
<JobRow key={job.id} job={job} item={item} node={node} nodes={nodes} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* SSE for live updates */}
|
||||
{hasActiveJobs && (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
var es = new EventSource('/execute/events');
|
||||
es.addEventListener('job_update', function(e) {
|
||||
var d = JSON.parse(e.data);
|
||||
var row = document.getElementById('job-row-' + d.id);
|
||||
if (!row) return;
|
||||
var statusCell = row.querySelector('.job-status');
|
||||
if (statusCell) statusCell.innerHTML = '<span class="badge badge-' + d.status + '">' + d.status + '</span>';
|
||||
var logCell = document.getElementById('job-log-' + d.id);
|
||||
if (logCell && d.output) logCell.textContent = d.output;
|
||||
});
|
||||
es.addEventListener('complete', function() { es.close(); location.reload(); });
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
const JobRow: FC<{ job: Job; item: MediaItem | null; node: Node | null; nodes: Node[] }> = ({
|
||||
job,
|
||||
item,
|
||||
node,
|
||||
nodes,
|
||||
}) => {
|
||||
const itemName = item
|
||||
? (item.type === 'Episode' && item.series_name
|
||||
? `${item.series_name} S${String(item.season_number ?? 0).padStart(2, '0')}E${String(item.episode_number ?? 0).padStart(2, '0')}`
|
||||
: item.name)
|
||||
: `Item #${job.item_id}`;
|
||||
|
||||
const cmdShort = job.command.length > 80 ? job.command.slice(0, 77) + '…' : job.command;
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr id={`job-row-${job.id}`}>
|
||||
<td class="mono">{job.id}</td>
|
||||
<td>
|
||||
<div class="truncate" style="max-width:200px;" title={itemName}>{itemName}</div>
|
||||
{item?.file_path && (
|
||||
<div class="mono muted truncate" style="font-size:0.72rem;max-width:200px;" title={item.file_path}>
|
||||
{item.file_path.split('/').pop()}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td class="mono" style="font-size:0.75rem;max-width:300px;">
|
||||
<span title={job.command}>{cmdShort}</span>
|
||||
</td>
|
||||
<td>
|
||||
{job.status === 'pending' ? (
|
||||
<form
|
||||
hx-post={`/execute/job/${job.id}/assign`}
|
||||
hx-target={`#job-row-${job.id}`}
|
||||
hx-swap="outerHTML"
|
||||
style="display:inline"
|
||||
>
|
||||
<select name="node_id" style="font-size:0.8rem;padding:0.2em 0.4em;" onchange="this.form.submit()">
|
||||
<option value="">Local</option>
|
||||
{nodes.map((n) => (
|
||||
<option key={n.id} value={n.id} selected={node?.id === n.id}>
|
||||
{n.name} ({n.host})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</form>
|
||||
) : (
|
||||
<span class="muted">{node?.name ?? 'Local'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span class={`badge badge-${job.status} job-status`}>{job.status}</span>
|
||||
{job.exit_code != null && job.exit_code !== 0 && (
|
||||
<span class="badge badge-error">exit {job.exit_code}</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="actions-col">
|
||||
{job.status === 'pending' && (
|
||||
<form method="post" action={`/execute/job/${job.id}/run`} style="display:inline">
|
||||
<button type="submit" data-size="sm">▶ Run</button>
|
||||
</form>
|
||||
)}
|
||||
{job.status === 'pending' && (
|
||||
<form method="post" action={`/execute/job/${job.id}/cancel`} style="display:inline">
|
||||
<button type="submit" class="secondary" data-size="sm">✕</button>
|
||||
</form>
|
||||
)}
|
||||
{(job.status === 'done' || job.status === 'error') && job.output && (
|
||||
<button
|
||||
data-size="sm"
|
||||
class="secondary"
|
||||
onclick={`document.getElementById('job-log-${job.id}').toggleAttribute('hidden')`}
|
||||
>
|
||||
Log
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{job.output && (
|
||||
<tr>
|
||||
<td colspan={6} style="padding:0;">
|
||||
<div id={`job-log-${job.id}`} hidden class="log-output">{job.output}</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { FC, PropsWithChildren } from 'hono/jsx';
|
||||
|
||||
interface LayoutProps {
|
||||
title?: string;
|
||||
activeNav?: 'dashboard' | 'scan' | 'review' | 'execute' | 'nodes' | 'setup';
|
||||
}
|
||||
|
||||
export const Layout: FC<PropsWithChildren<LayoutProps>> = ({ children, title = 'netfelix-audio-fix', activeNav }) => (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title} — netfelix-audio-fix</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
|
||||
/>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js" defer />
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer />
|
||||
</head>
|
||||
<body>
|
||||
<nav class="app-nav">
|
||||
<a href="/" class="brand">🎬 netfelix-audio-fix</a>
|
||||
<a href="/scan" class={activeNav === 'scan' ? 'active' : ''}>Scan</a>
|
||||
<a href="/review" class={activeNav === 'review' ? 'active' : ''}>Review</a>
|
||||
<a href="/execute" class={activeNav === 'execute' ? 'active' : ''}>Execute</a>
|
||||
<div class="spacer" />
|
||||
<a href="/nodes" class={activeNav === 'nodes' ? 'active' : ''}>Nodes</a>
|
||||
<a href="/setup" class={activeNav === 'setup' ? 'active' : ''}>Setup</a>
|
||||
</nav>
|
||||
<main class="page">{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
/** Render an HTML fragment suitable for HTMX partial swap. */
|
||||
export const Fragment: FC<PropsWithChildren> = ({ children }) => <>{children}</>;
|
||||
@@ -1,130 +0,0 @@
|
||||
import type { FC } from 'hono/jsx';
|
||||
import { Layout } from './layout';
|
||||
import type { Node } from '../types';
|
||||
|
||||
interface NodesPageProps {
|
||||
nodes: Node[];
|
||||
}
|
||||
|
||||
export const NodesPage: FC<NodesPageProps> = ({ nodes }) => (
|
||||
<Layout title="Nodes" activeNav="nodes">
|
||||
<div class="page-header">
|
||||
<h1>Remote Nodes</h1>
|
||||
</div>
|
||||
|
||||
<p class="muted">
|
||||
Remote nodes run FFmpeg over SSH on shared storage. The path to the media file must be
|
||||
identical on both this server and the remote node.
|
||||
</p>
|
||||
|
||||
{/* Add node form */}
|
||||
<article>
|
||||
<header><strong>Add Node</strong></header>
|
||||
<form
|
||||
hx-post="/nodes"
|
||||
hx-target="#nodes-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-encoding="multipart/form-data"
|
||||
>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||
<label>
|
||||
Name
|
||||
<input type="text" name="name" placeholder="my-server" required />
|
||||
</label>
|
||||
<label>
|
||||
Host
|
||||
<input type="text" name="host" placeholder="192.168.1.200" required />
|
||||
</label>
|
||||
<label>
|
||||
SSH Port
|
||||
<input type="number" name="port" value="22" min="1" max="65535" />
|
||||
</label>
|
||||
<label>
|
||||
Username
|
||||
<input type="text" name="username" placeholder="root" required />
|
||||
</label>
|
||||
<label>
|
||||
FFmpeg path
|
||||
<input type="text" name="ffmpeg_path" value="ffmpeg" />
|
||||
</label>
|
||||
<label>
|
||||
Work directory
|
||||
<input type="text" name="work_dir" value="/tmp" />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Private key (PEM)
|
||||
<input type="file" name="private_key" accept=".pem,.key,text/plain" required />
|
||||
<small>Upload your SSH private key file. Stored securely in the database.</small>
|
||||
</label>
|
||||
<button type="submit">Add Node</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
{/* Node list */}
|
||||
<NodesList nodes={nodes} />
|
||||
</Layout>
|
||||
);
|
||||
|
||||
export const NodesList: FC<{ nodes: Node[] }> = ({ nodes }) => (
|
||||
<div id="nodes-list">
|
||||
{nodes.length === 0 ? (
|
||||
<p class="muted">No nodes configured. Add one above.</p>
|
||||
) : (
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Host</th>
|
||||
<th>Port</th>
|
||||
<th>User</th>
|
||||
<th>FFmpeg</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodes.map((node) => (
|
||||
<tr key={node.id} id={`node-row-${node.id}`}>
|
||||
<td><strong>{node.name}</strong></td>
|
||||
<td class="mono">{node.host}</td>
|
||||
<td class="mono">{node.port}</td>
|
||||
<td class="mono">{node.username}</td>
|
||||
<td class="mono">{node.ffmpeg_path}</td>
|
||||
<td>
|
||||
<span
|
||||
id={`node-status-${node.id}`}
|
||||
class={`badge badge-${node.status === 'ok' ? 'done' : node.status === 'error' ? 'error' : 'pending'}`}
|
||||
>
|
||||
{node.status}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions-col">
|
||||
<button
|
||||
data-size="sm"
|
||||
hx-post={`/nodes/${node.id}/test`}
|
||||
hx-target={`#node-status-${node.id}`}
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator={`#node-spinner-${node.id}`}
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
<span id={`node-spinner-${node.id}`} class="htmx-indicator muted" aria-busy="true" />
|
||||
<form method="post" action={`/nodes/${node.id}/delete`} style="display:inline"
|
||||
onsubmit="return confirm('Remove node?')">
|
||||
<button type="submit" class="secondary" data-size="sm">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const NodeStatusBadge: FC<{ status: string }> = ({ status }) => (
|
||||
<span class={`badge badge-${status === 'ok' ? 'done' : status === 'error' ? 'error' : 'pending'}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
@@ -1,371 +0,0 @@
|
||||
import type { FC } from 'hono/jsx';
|
||||
import { Layout } from './layout';
|
||||
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from '../types';
|
||||
import { streamLabel } from '../services/ffmpeg';
|
||||
|
||||
// ─── Language name map (ISO 639-2 → display name) ─────────────────────────────
|
||||
|
||||
const LANG_NAMES: Record<string, string> = {
|
||||
eng: 'English', deu: 'German', spa: 'Spanish', fra: 'French', ita: 'Italian',
|
||||
por: 'Portuguese', jpn: 'Japanese', kor: 'Korean', zho: 'Chinese', ara: 'Arabic',
|
||||
rus: 'Russian', nld: 'Dutch', swe: 'Swedish', nor: 'Norwegian', dan: 'Danish',
|
||||
fin: 'Finnish', pol: 'Polish', tur: 'Turkish', tha: 'Thai', hin: 'Hindi',
|
||||
hun: 'Hungarian', ces: 'Czech', ron: 'Romanian', ell: 'Greek', heb: 'Hebrew',
|
||||
fas: 'Persian', ukr: 'Ukrainian', ind: 'Indonesian', msa: 'Malay', vie: 'Vietnamese',
|
||||
cat: 'Catalan', tam: 'Tamil', tel: 'Telugu', slk: 'Slovak', hrv: 'Croatian',
|
||||
bul: 'Bulgarian', srp: 'Serbian', slv: 'Slovenian', lav: 'Latvian', lit: 'Lithuanian',
|
||||
est: 'Estonian', isl: 'Icelandic', nob: 'Norwegian Bokmål', nno: 'Norwegian Nynorsk',
|
||||
};
|
||||
|
||||
function langName(code: string | null): string {
|
||||
if (!code) return '—';
|
||||
return LANG_NAMES[code.toLowerCase()] ?? code.toUpperCase();
|
||||
}
|
||||
|
||||
// ─── List page ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ReviewListProps {
|
||||
items: Array<{
|
||||
item: MediaItem;
|
||||
plan: ReviewPlan | null;
|
||||
removeCount: number;
|
||||
keepCount: number;
|
||||
}>;
|
||||
filter: string;
|
||||
totalCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
const FILTER_TABS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'needs_action', label: 'Needs Action' },
|
||||
{ key: 'noop', label: 'No Change' },
|
||||
{ key: 'manual', label: 'Manual Review' },
|
||||
{ key: 'approved', label: 'Approved' },
|
||||
{ key: 'skipped', label: 'Skipped' },
|
||||
{ key: 'done', label: 'Done' },
|
||||
{ key: 'error', label: 'Error' },
|
||||
];
|
||||
|
||||
export const ReviewListPage: FC<ReviewListProps> = ({ items, filter, totalCounts }) => (
|
||||
<Layout title="Review" activeNav="review">
|
||||
<div class="page-header">
|
||||
<h1>Review</h1>
|
||||
{items.some((i) => i.plan?.status === 'pending' && !i.plan.is_noop) && (
|
||||
<form method="post" action="/review/approve-all" style="display:inline">
|
||||
<button type="submit" data-size="sm">Approve all pending</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="filter-tabs">
|
||||
{FILTER_TABS.map((tab) => (
|
||||
<a
|
||||
key={tab.key}
|
||||
href={`/review?filter=${tab.key}`}
|
||||
class={filter === tab.key ? 'active' : ''}
|
||||
>
|
||||
{tab.label}
|
||||
{totalCounts[tab.key] != null && (
|
||||
<> <span class="badge">{totalCounts[tab.key]}</span></>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p class="muted">No items match this filter.</p>
|
||||
) : (
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Orig. Language</th>
|
||||
<th>Remove</th>
|
||||
<th>Keep</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(({ item, plan, removeCount, keepCount }) => (
|
||||
<ReviewRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
plan={plan}
|
||||
removeCount={removeCount}
|
||||
keepCount={keepCount}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
|
||||
const ReviewRow: FC<{
|
||||
item: MediaItem;
|
||||
plan: ReviewPlan | null;
|
||||
removeCount: number;
|
||||
keepCount: number;
|
||||
}> = ({ item, plan, removeCount, keepCount }) => {
|
||||
const statusKey = plan?.is_noop ? 'noop' : (plan?.status ?? 'pending');
|
||||
const needsManual = item.needs_review && !item.original_language;
|
||||
const displayName = item.type === 'Episode' && item.series_name
|
||||
? `${item.series_name} S${String(item.season_number ?? 0).padStart(2, '0')}E${String(item.episode_number ?? 0).padStart(2, '0')} — ${item.name}`
|
||||
: item.name;
|
||||
|
||||
return (
|
||||
<tr
|
||||
id={`row-${item.id}`}
|
||||
hx-get={`/review/${item.id}`}
|
||||
hx-target={`#detail-${item.id}`}
|
||||
hx-swap="innerHTML"
|
||||
style="cursor:pointer;"
|
||||
>
|
||||
<td>
|
||||
<div class="truncate" title={displayName}>{displayName}</div>
|
||||
{item.year && <span class="muted" style="font-size:0.75rem;"> ({item.year})</span>}
|
||||
</td>
|
||||
<td><span class="badge">{item.type}</span></td>
|
||||
<td>
|
||||
{needsManual ? (
|
||||
<span class="badge badge-manual">Manual</span>
|
||||
) : (
|
||||
<span title={item.original_language ?? ''}>{langName(item.original_language)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{removeCount > 0 ? (
|
||||
<span class="badge badge-remove">{removeCount} stream{removeCount !== 1 ? 's' : ''}</span>
|
||||
) : (
|
||||
<span class="muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td><span class="muted">{keepCount}</span></td>
|
||||
<td>
|
||||
<span class={`badge badge-${statusKey}`}>
|
||||
{plan?.is_noop ? 'no change' : (plan?.status ?? 'pending')}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions-col" onclick="event.stopPropagation()">
|
||||
{plan && plan.status === 'pending' && !plan.is_noop && (
|
||||
<form method="post" action={`/review/${item.id}/approve`} style="display:inline">
|
||||
<button type="submit" data-size="sm">Approve</button>
|
||||
</form>
|
||||
)}
|
||||
{plan && plan.status === 'pending' && (
|
||||
<form method="post" action={`/review/${item.id}/skip`} style="display:inline">
|
||||
<button type="submit" class="secondary" data-size="sm">Skip</button>
|
||||
</form>
|
||||
)}
|
||||
<a href={`/review/${item.id}`} data-size="sm" role="button" class="secondary">Detail</a>
|
||||
</td>
|
||||
{/* Hidden row for inline detail expansion */}
|
||||
<tr id={`detail-${item.id}`} style="display:none;" />
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Detail page ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface ReviewDetailProps {
|
||||
item: MediaItem;
|
||||
streams: MediaStream[];
|
||||
plan: ReviewPlan | null;
|
||||
decisions: StreamDecision[];
|
||||
command: string | null;
|
||||
}
|
||||
|
||||
export const ReviewDetailPage: FC<ReviewDetailProps> = ({
|
||||
item,
|
||||
streams,
|
||||
plan,
|
||||
decisions,
|
||||
command,
|
||||
}) => (
|
||||
<Layout title={`Review — ${item.name}`} activeNav="review">
|
||||
<div class="page-header">
|
||||
<h1>
|
||||
<a href="/review" style="font-weight:normal;margin-right:0.5rem;">← Review</a>
|
||||
{item.name}
|
||||
</h1>
|
||||
</div>
|
||||
<ReviewDetailFragment item={item} streams={streams} plan={plan} decisions={decisions} command={command} />
|
||||
</Layout>
|
||||
);
|
||||
|
||||
/** The detail fragment — also rendered as HTMX partial for inline expansion. */
|
||||
export const ReviewDetailFragment: FC<ReviewDetailProps> = ({
|
||||
item,
|
||||
streams,
|
||||
plan,
|
||||
decisions,
|
||||
command,
|
||||
}) => {
|
||||
const statusKey = plan?.is_noop ? 'noop' : (plan?.status ?? 'pending');
|
||||
|
||||
return (
|
||||
<div class="detail-panel" id={`detail-panel-${item.id}`}>
|
||||
{/* Meta */}
|
||||
<dl class="detail-meta">
|
||||
<div>
|
||||
<dt>Type</dt>
|
||||
<dd>{item.type}</dd>
|
||||
</div>
|
||||
{item.series_name && (
|
||||
<div>
|
||||
<dt>Series</dt>
|
||||
<dd>{item.series_name}</dd>
|
||||
</div>
|
||||
)}
|
||||
{item.year && (
|
||||
<div>
|
||||
<dt>Year</dt>
|
||||
<dd>{item.year}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt>Container</dt>
|
||||
<dd class="mono">{item.container ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>File size</dt>
|
||||
<dd>{item.file_size ? formatBytes(item.file_size) : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd><span class={`badge badge-${statusKey}`}>{statusKey}</span></dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="mono muted" style="font-size:0.78rem;margin-bottom:1rem;word-break:break-all;">
|
||||
{item.file_path}
|
||||
</div>
|
||||
|
||||
{/* Notes / warnings */}
|
||||
{plan?.notes && (
|
||||
<div class="alert alert-warning">{plan.notes}</div>
|
||||
)}
|
||||
{item.needs_review && !item.original_language && (
|
||||
<div class="alert alert-warning">
|
||||
Original language unknown — audio tracks will NOT be filtered until you set it below.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Language override */}
|
||||
<div class="flex-row" style="margin-bottom:1rem;">
|
||||
<label style="margin:0;font-size:0.85rem;">Original language:</label>
|
||||
<select
|
||||
class="lang-select"
|
||||
hx-patch={`/review/${item.id}/language`}
|
||||
hx-target={`#detail-panel-${item.id}`}
|
||||
hx-swap="outerHTML"
|
||||
name="language"
|
||||
>
|
||||
<option value="">— Unknown —</option>
|
||||
{Object.entries(LANG_NAMES).map(([code, name]) => (
|
||||
<option key={code} value={code} selected={item.original_language === code}>
|
||||
{name} ({code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{item.orig_lang_source && (
|
||||
<span class="badge muted">{item.orig_lang_source}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stream decisions table */}
|
||||
<table class="stream-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Type</th>
|
||||
<th>Codec</th>
|
||||
<th>Language</th>
|
||||
<th>Title / Info</th>
|
||||
<th>Flags</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{streams.map((s) => {
|
||||
const dec = decisions.find((d) => d.stream_id === s.id);
|
||||
const action = dec?.action ?? 'keep';
|
||||
return (
|
||||
<tr key={s.id} class={`stream-row-${action}`}>
|
||||
<td class="mono">{s.stream_index}</td>
|
||||
<td><span class="badge">{s.type}</span></td>
|
||||
<td class="mono">{s.codec ?? '—'}</td>
|
||||
<td>{langName(s.language)} {s.language ? <span class="muted mono">({s.language})</span> : null}</td>
|
||||
<td class="truncate" title={s.title ?? ''}>
|
||||
{s.title ?? (s.type === 'Audio' && s.channels ? `${s.channels}ch ${s.channel_layout ?? ''}` : '—')}
|
||||
</td>
|
||||
<td>
|
||||
{s.is_default ? <span class="badge">default</span> : null}
|
||||
{' '}{s.is_forced ? <span class="badge badge-manual">forced</span> : null}
|
||||
{' '}{s.is_hearing_impaired ? <span class="badge">CC</span> : null}
|
||||
</td>
|
||||
<td>
|
||||
{plan?.status === 'pending' && (
|
||||
<form
|
||||
hx-patch={`/review/${item.id}/stream/${s.id}`}
|
||||
hx-target={`#detail-panel-${item.id}`}
|
||||
hx-swap="outerHTML"
|
||||
style="display:inline"
|
||||
>
|
||||
<input type="hidden" name="action" value={action === 'keep' ? 'remove' : 'keep'} />
|
||||
<button
|
||||
type="submit"
|
||||
class={action === 'keep' ? 'toggle-keep' : 'toggle-remove'}
|
||||
>
|
||||
{action === 'keep' ? '✓ Keep' : '✗ Remove'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{plan?.status !== 'pending' && (
|
||||
<span class={`badge badge-${action}`}>{action}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* FFmpeg command preview */}
|
||||
{command && (
|
||||
<div style="margin-top:1.5rem;">
|
||||
<div class="muted" style="font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.35rem;">
|
||||
FFmpeg command
|
||||
</div>
|
||||
<textarea class="command-preview" readonly rows={3}>{command}</textarea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Approve / skip */}
|
||||
{plan?.status === 'pending' && !plan.is_noop && (
|
||||
<div class="flex-row" style="margin-top:1.5rem;">
|
||||
<form method="post" action={`/review/${item.id}/approve`} style="display:inline">
|
||||
<button type="submit">✓ Approve</button>
|
||||
</form>
|
||||
<form method="post" action={`/review/${item.id}/skip`} style="display:inline">
|
||||
<button type="submit" class="secondary">Skip</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
{plan?.is_noop ? (
|
||||
<div class="alert alert-success" style="margin-top:1rem;">
|
||||
This file is already clean — no changes needed.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import type { FC } from 'hono/jsx';
|
||||
import { Layout } from './layout';
|
||||
|
||||
interface ScanPageProps {
|
||||
running: boolean;
|
||||
progress: { scanned: number; total: number; errors: number };
|
||||
recentItems: Array<{ name: string; type: string; scan_status: string }>;
|
||||
}
|
||||
|
||||
export const ScanPage: FC<ScanPageProps> = ({ running, progress, recentItems }) => {
|
||||
const pct = progress.total > 0 ? Math.round((progress.scanned / progress.total) * 100) : 0;
|
||||
return (
|
||||
<Layout title="Scan" activeNav="scan">
|
||||
<div class="page-header">
|
||||
<h1>Library Scan</h1>
|
||||
</div>
|
||||
|
||||
<article id="scan-status">
|
||||
<ScanStatusFragment running={running} progress={progress} />
|
||||
</article>
|
||||
|
||||
{running && (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
const es = new EventSource('/scan/events');
|
||||
es.addEventListener('progress', function(e) {
|
||||
const d = JSON.parse(e.data);
|
||||
document.getElementById('progress-bar').style.width = (d.total > 0 ? Math.round(d.scanned / d.total * 100) : 0) + '%';
|
||||
document.getElementById('progress-text').textContent = d.scanned + ' / ' + d.total;
|
||||
document.getElementById('current-item').textContent = d.current_item ?? '';
|
||||
if (d.errors > 0) document.getElementById('error-count').textContent = d.errors + ' error(s)';
|
||||
});
|
||||
es.addEventListener('log', function(e) {
|
||||
const d = JSON.parse(e.data);
|
||||
const log = document.getElementById('scan-log-body');
|
||||
if (!log) return;
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = '<td>' + d.type + '</td><td>' + escHtml(d.name) + '</td><td><span class="badge badge-' + d.status + '">' + d.status + '</span></td>';
|
||||
log.prepend(tr);
|
||||
while (log.children.length > 100) log.removeChild(log.lastChild);
|
||||
});
|
||||
es.addEventListener('complete', function() {
|
||||
es.close();
|
||||
location.reload();
|
||||
});
|
||||
es.addEventListener('error', function() {
|
||||
es.close();
|
||||
location.reload();
|
||||
});
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<h3 style="margin-top:2rem;">Recent items</h3>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="scan-log-body">
|
||||
{recentItems.map((item, i) => (
|
||||
<tr key={i}>
|
||||
<td>{item.type}</td>
|
||||
<td>{item.name}</td>
|
||||
<td>
|
||||
<span class={`badge badge-${item.scan_status}`}>{item.scan_status}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export const ScanStatusFragment: FC<{
|
||||
running: boolean;
|
||||
progress: { scanned: number; total: number; errors: number };
|
||||
}> = ({ running, progress }) => {
|
||||
const pct = progress.total > 0 ? Math.round((progress.scanned / progress.total) * 100) : 0;
|
||||
return (
|
||||
<>
|
||||
<div class="flex-row" style="margin-bottom:1rem;">
|
||||
<strong>{running ? 'Scan in progress…' : 'Scan idle'}</strong>
|
||||
{running ? (
|
||||
<form method="post" action="/scan/stop" style="display:inline">
|
||||
<button type="submit" class="secondary" data-size="sm">Stop</button>
|
||||
</form>
|
||||
) : (
|
||||
<form method="post" action="/scan/start" style="display:inline">
|
||||
<button type="submit" data-size="sm">Start Scan</button>
|
||||
</form>
|
||||
)}
|
||||
{progress.errors > 0 && (
|
||||
<span id="error-count" class="badge badge-error">{progress.errors} error(s)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(running || progress.total > 0) && (
|
||||
<>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-bar" id="progress-bar" style={`width:${pct}%`} />
|
||||
</div>
|
||||
<div class="flex-row muted" style="font-size:0.82rem;">
|
||||
<span id="progress-text">{progress.scanned} / {progress.total}</span>
|
||||
<span id="current-item" class="truncate" style="max-width:400px;" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,223 +0,0 @@
|
||||
import type { FC } from 'hono/jsx';
|
||||
import { Layout } from './layout';
|
||||
|
||||
interface SetupProps {
|
||||
step: 1 | 2 | 3 | 4;
|
||||
config: Record<string, string>;
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{ n: 1, label: 'Jellyfin' },
|
||||
{ n: 2, label: 'Radarr' },
|
||||
{ n: 3, label: 'Sonarr' },
|
||||
{ n: 4, label: 'Finish' },
|
||||
];
|
||||
|
||||
export const SetupPage: FC<SetupProps> = ({ step, config }) => (
|
||||
<Layout title="Setup" activeNav="setup">
|
||||
<div class="page-header">
|
||||
<h1>Setup Wizard</h1>
|
||||
</div>
|
||||
|
||||
<div class="wizard-steps">
|
||||
{STEPS.map((s) => (
|
||||
<div
|
||||
key={s.n}
|
||||
class={`wizard-step ${step === s.n ? 'active' : ''} ${step > s.n ? 'done' : ''}`}
|
||||
>
|
||||
{step > s.n ? '✓ ' : ''}{s.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{step === 1 && <JellyfinStep config={config} />}
|
||||
{step === 2 && <RadarrStep config={config} />}
|
||||
{step === 3 && <SonarrStep config={config} />}
|
||||
{step === 4 && <FinishStep config={config} />}
|
||||
</Layout>
|
||||
);
|
||||
|
||||
const JellyfinStep: FC<{ config: Record<string, string> }> = ({ config }) => (
|
||||
<article>
|
||||
<header><strong>Connect to Jellyfin</strong></header>
|
||||
<form
|
||||
hx-post="/setup/jellyfin"
|
||||
hx-target="#setup-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#spinner"
|
||||
>
|
||||
<label>
|
||||
Jellyfin URL
|
||||
<input
|
||||
type="url"
|
||||
name="url"
|
||||
placeholder="http://192.168.1.100:8096"
|
||||
value={config.jellyfin_url ?? ''}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
API Key
|
||||
<input
|
||||
type="text"
|
||||
name="api_key"
|
||||
placeholder="your-api-key"
|
||||
value={config.jellyfin_api_key ?? ''}
|
||||
required
|
||||
/>
|
||||
<small>
|
||||
Find it in Jellyfin → Dashboard → API Keys → New API Key
|
||||
</small>
|
||||
</label>
|
||||
<div class="flex-row">
|
||||
<button type="submit">Test & Save</button>
|
||||
<span id="spinner" class="htmx-indicator muted" aria-busy="true" />
|
||||
</div>
|
||||
</form>
|
||||
<div id="setup-result" />
|
||||
</article>
|
||||
);
|
||||
|
||||
const RadarrStep: FC<{ config: Record<string, string> }> = ({ config }) => (
|
||||
<article>
|
||||
<header><strong>Connect to Radarr (optional)</strong></header>
|
||||
<p class="muted">
|
||||
Radarr provides accurate original-language data for movies. Skip if not using Radarr.
|
||||
</p>
|
||||
<form
|
||||
hx-post="/setup/radarr"
|
||||
hx-target="#setup-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#spinner"
|
||||
>
|
||||
<label>
|
||||
Radarr URL
|
||||
<input
|
||||
type="url"
|
||||
name="url"
|
||||
placeholder="http://192.168.1.100:7878"
|
||||
value={config.radarr_url ?? ''}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
API Key
|
||||
<input
|
||||
type="text"
|
||||
name="api_key"
|
||||
placeholder="your-api-key"
|
||||
value={config.radarr_api_key ?? ''}
|
||||
/>
|
||||
</label>
|
||||
<div class="flex-row">
|
||||
<button type="submit">Test & Save</button>
|
||||
<a href="/setup?step=3" role="button" class="secondary" data-size="sm">Skip</a>
|
||||
<span id="spinner" class="htmx-indicator muted" aria-busy="true" />
|
||||
</div>
|
||||
</form>
|
||||
<div id="setup-result" />
|
||||
</article>
|
||||
);
|
||||
|
||||
const SonarrStep: FC<{ config: Record<string, string> }> = ({ config }) => (
|
||||
<article>
|
||||
<header><strong>Connect to Sonarr (optional)</strong></header>
|
||||
<p class="muted">
|
||||
Sonarr provides original-language data for TV series. Skip if not using Sonarr.
|
||||
</p>
|
||||
<form
|
||||
hx-post="/setup/sonarr"
|
||||
hx-target="#setup-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#spinner"
|
||||
>
|
||||
<label>
|
||||
Sonarr URL
|
||||
<input
|
||||
type="url"
|
||||
name="url"
|
||||
placeholder="http://192.168.1.100:8989"
|
||||
value={config.sonarr_url ?? ''}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
API Key
|
||||
<input
|
||||
type="text"
|
||||
name="api_key"
|
||||
placeholder="your-api-key"
|
||||
value={config.sonarr_api_key ?? ''}
|
||||
/>
|
||||
</label>
|
||||
<div class="flex-row">
|
||||
<button type="submit">Test & Save</button>
|
||||
<a href="/setup?step=4" role="button" class="secondary" data-size="sm">Skip</a>
|
||||
<span id="spinner" class="htmx-indicator muted" aria-busy="true" />
|
||||
</div>
|
||||
</form>
|
||||
<div id="setup-result" />
|
||||
</article>
|
||||
);
|
||||
|
||||
const FinishStep: FC<{ config: Record<string, string> }> = ({ config }) => {
|
||||
const subtitleLangs: string[] = JSON.parse(config.subtitle_languages ?? '["eng","deu","spa"]');
|
||||
return (
|
||||
<article>
|
||||
<header><strong>Language Rules</strong></header>
|
||||
<p>Confirm which subtitle languages to keep in all media files.</p>
|
||||
<form
|
||||
hx-post="/setup/complete"
|
||||
hx-push-url="/"
|
||||
hx-target="body"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<fieldset>
|
||||
<legend>Keep subtitles in:</legend>
|
||||
{[
|
||||
{ code: 'eng', label: 'English' },
|
||||
{ code: 'deu', label: 'German (Deutsch)' },
|
||||
{ code: 'spa', label: 'Spanish (Español)' },
|
||||
{ code: 'fra', label: 'French (Français)' },
|
||||
{ code: 'ita', label: 'Italian (Italiano)' },
|
||||
{ code: 'por', label: 'Portuguese' },
|
||||
{ code: 'jpn', label: 'Japanese' },
|
||||
].map(({ code, label }) => (
|
||||
<label key={code}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="subtitle_lang"
|
||||
value={code}
|
||||
checked={subtitleLangs.includes(code)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<small class="muted">
|
||||
Forced subtitles and CC/SDH tracks are always kept regardless of language.
|
||||
</small>
|
||||
<br /><br />
|
||||
<button type="submit">Complete Setup →</button>
|
||||
</form>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
/** Partial: connection test result fragment for HTMX swap. */
|
||||
export const ConnStatusFragment: FC<{ ok: boolean; error?: string; nextUrl?: string }> = ({
|
||||
ok,
|
||||
error,
|
||||
nextUrl,
|
||||
}) => (
|
||||
<div>
|
||||
{ok ? (
|
||||
<div class="conn-status ok">✓ Connected successfully</div>
|
||||
) : (
|
||||
<div class="conn-status error">✗ {error ?? 'Connection failed'}</div>
|
||||
)}
|
||||
{ok && nextUrl && (
|
||||
<a href={nextUrl} role="button" style="margin-top:1rem;display:inline-block;">
|
||||
Continue →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -4,11 +4,13 @@
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["bun-types"],
|
||||
"lib": ["ESNext"]
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
"include": ["src/**/*", "vite.config.ts"]
|
||||
}
|
||||
|
||||
14
tsconfig.server.json
Normal file
14
tsconfig.server.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["bun-types"],
|
||||
"lib": ["ESNext"]
|
||||
},
|
||||
"include": ["server/**/*"]
|
||||
}
|
||||
28
vite.config.ts
Normal file
28
vite.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
TanStackRouterVite({ target: 'react', autoCodeSplitting: true }),
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:3000', changeOrigin: true },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user