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
|
data/*.db-wal
|
||||||
bun.lockb
|
bun.lockb
|
||||||
.env
|
.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
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
COPY package.json bun.lock* ./
|
RUN npm install
|
||||||
RUN bun install --frozen-lockfile
|
|
||||||
|
|
||||||
COPY . .
|
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
|
EXPOSE 3000
|
||||||
ENV DATA_DIR=/data
|
ENV DATA_DIR=/data
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
|
CMD ["bun", "run", "server/index.tsx"]
|
||||||
CMD ["bun", "run", "src/server.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",
|
"name": "netfelix-audio-fix",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-form": "^1.28.3",
|
||||||
|
"@tanstack/react-router": "^1.163.3",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"hono": "^4",
|
"hono": "^4",
|
||||||
|
"react": "19",
|
||||||
|
"react-dom": "19",
|
||||||
"ssh2": "^1",
|
"ssh2": "^1",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"zod": "^4.3.6",
|
||||||
|
"zustand": "^5.0.11",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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",
|
"@types/ssh2": "^1",
|
||||||
|
"@vitejs/plugin-react-swc": "^4.2.3",
|
||||||
"bun-types": "latest",
|
"bun-types": "latest",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"tailwindcss": "^4.2.1",
|
||||||
|
"vite": "^7.3.1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
"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=="],
|
"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=="],
|
"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",
|
"name": "netfelix-audio-fix",
|
||||||
"version": "2026.02.26",
|
"version": "2026.02.26",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun --hot src/server.tsx",
|
"dev:server": "NODE_ENV=development bun --hot server/index.tsx",
|
||||||
"start": "bun src/server.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": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-form": "^1.28.3",
|
||||||
|
"@tanstack/react-router": "^1.163.3",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"hono": "^4",
|
"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": {
|
"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",
|
"@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 { Hono } from 'hono';
|
||||||
import { stream } from 'hono/streaming';
|
import { stream } from 'hono/streaming';
|
||||||
import { getDb, getConfig, setConfig, getAllConfig } from '../db/index';
|
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 radarrLang } from '../services/radarr';
|
||||||
import { getOriginalLanguage as sonarrLang } from '../services/sonarr';
|
import { getOriginalLanguage as sonarrLang } from '../services/sonarr';
|
||||||
import { analyzeItem } from '../services/analyzer';
|
import { analyzeItem } from '../services/analyzer';
|
||||||
import { buildCommand } from '../services/ffmpeg';
|
|
||||||
import type { MediaItem, MediaStream } from '../types';
|
import type { MediaItem, MediaStream } from '../types';
|
||||||
import { ScanPage } from '../views/scan';
|
|
||||||
import { DashboardPage } from '../views/dashboard';
|
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
// ─── State: single in-process scan ───────────────────────────────────────────
|
// ─── State ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let scanAbort: AbortController | null = null;
|
let scanAbort: AbortController | null = null;
|
||||||
const scanListeners = new Set<(data: string) => void>();
|
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);
|
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) => {
|
app.get('/', (c) => {
|
||||||
const db = getDb();
|
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 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 errors = (db.prepare("SELECT COUNT(*) as n FROM media_items WHERE scan_status = 'error'").get() as { n: number }).n;
|
||||||
const recentItems = db.prepare(
|
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 }[];
|
).all() as { name: string; type: string; scan_status: string }[];
|
||||||
|
|
||||||
return c.html(
|
return c.json({ running, progress: { scanned, total, errors }, recentItems, scanLimit: currentScanLimit() });
|
||||||
<ScanPage
|
|
||||||
running={running}
|
|
||||||
progress={{ scanned, total, errors }}
|
|
||||||
recentItems={recentItems}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Start scan ───────────────────────────────────────────────────────────────
|
// ─── Start ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/start', async (c) => {
|
app.post('/start', async (c) => {
|
||||||
if (getConfig('scan_running') === '1') {
|
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');
|
setConfig('scan_running', '1');
|
||||||
// Start scan in background (fire and forget)
|
|
||||||
runScan().catch((err) => {
|
runScan(limit).catch((err) => {
|
||||||
console.error('Scan error:', err);
|
console.error('Scan error:', err);
|
||||||
setConfig('scan_running', '0');
|
setConfig('scan_running', '0');
|
||||||
emitSse('error', { message: String(err) });
|
emitSse('error', { message: String(err) });
|
||||||
});
|
});
|
||||||
return c.redirect('/scan');
|
|
||||||
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Stop scan ────────────────────────────────────────────────────────────────
|
// ─── Stop ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/stop', (c) => {
|
app.post('/stop', (c) => {
|
||||||
scanAbort?.abort();
|
scanAbort?.abort();
|
||||||
setConfig('scan_running', '0');
|
setConfig('scan_running', '0');
|
||||||
return c.redirect('/scan');
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── SSE stream ───────────────────────────────────────────────────────────────
|
// ─── SSE ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/events', (c) => {
|
app.get('/events', (c) => {
|
||||||
return stream(c, async (s) => {
|
return stream(c, async (s) => {
|
||||||
@@ -93,12 +96,10 @@ app.get('/events', (c) => {
|
|||||||
} else {
|
} else {
|
||||||
await new Promise<void>((res) => {
|
await new Promise<void>((res) => {
|
||||||
resolve = res;
|
resolve = res;
|
||||||
setTimeout(res, 15_000); // keepalive every 15s
|
setTimeout(res, 25_000);
|
||||||
});
|
});
|
||||||
resolve = null;
|
resolve = null;
|
||||||
if (queue.length === 0) {
|
if (queue.length === 0) await s.write(': keepalive\n\n');
|
||||||
await s.write(': keepalive\n\n');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -109,39 +110,42 @@ app.get('/events', (c) => {
|
|||||||
|
|
||||||
// ─── Core scan logic ──────────────────────────────────────────────────────────
|
// ─── Core scan logic ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function runScan(): Promise<void> {
|
async function runScan(limit: number | null = null): Promise<void> {
|
||||||
scanAbort = new AbortController();
|
scanAbort = new AbortController();
|
||||||
const { signal } = scanAbort;
|
const { signal } = scanAbort;
|
||||||
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const cfg = getAllConfig();
|
|
||||||
|
|
||||||
const jellyfinCfg = {
|
if (isDev) {
|
||||||
url: cfg.jellyfin_url,
|
db.prepare('DELETE FROM stream_decisions').run();
|
||||||
apiKey: cfg.jellyfin_api_key,
|
db.prepare('DELETE FROM review_plans').run();
|
||||||
userId: cfg.jellyfin_user_id,
|
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 subtitleLanguages: string[] = JSON.parse(cfg.subtitle_languages ?? '["eng","deu","spa"]');
|
||||||
const radarrEnabled = cfg.radarr_enabled === '1';
|
const radarrEnabled = cfg.radarr_enabled === '1';
|
||||||
const sonarrEnabled = cfg.sonarr_enabled === '1';
|
const sonarrEnabled = cfg.sonarr_enabled === '1';
|
||||||
|
|
||||||
let scanned = 0;
|
let processed = 0;
|
||||||
let errors = 0;
|
let errors = 0;
|
||||||
let total = 0;
|
let total = isDev ? 250 : 0;
|
||||||
|
|
||||||
// Count total items first (rough)
|
if (!isDev) {
|
||||||
try {
|
try {
|
||||||
const countUrl = new URL(`${jellyfinCfg.url}/Users/${jellyfinCfg.userId}/Items`);
|
const countUrl = new URL(`${jellyfinCfg.url}/Users/${jellyfinCfg.userId}/Items`);
|
||||||
countUrl.searchParams.set('Recursive', 'true');
|
countUrl.searchParams.set('Recursive', 'true');
|
||||||
countUrl.searchParams.set('IncludeItemTypes', 'Movie,Episode');
|
countUrl.searchParams.set('IncludeItemTypes', 'Movie,Episode');
|
||||||
countUrl.searchParams.set('Limit', '1');
|
countUrl.searchParams.set('Limit', '1');
|
||||||
const countRes = await fetch(countUrl.toString(), {
|
const countRes = await fetch(countUrl.toString(), { headers: { 'X-Emby-Token': jellyfinCfg.apiKey } });
|
||||||
headers: { 'X-Emby-Token': jellyfinCfg.apiKey },
|
if (countRes.ok) {
|
||||||
});
|
const body = (await countRes.json()) as { TotalRecordCount: number };
|
||||||
if (countRes.ok) {
|
total = limit != null ? Math.min(limit, body.TotalRecordCount) : body.TotalRecordCount;
|
||||||
const body = await countRes.json() as { TotalRecordCount: number };
|
}
|
||||||
total = body.TotalRecordCount;
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
const upsertItem = db.prepare(`
|
const upsertItem = db.prepare(`
|
||||||
INSERT INTO media_items (
|
INSERT INTO media_items (
|
||||||
@@ -150,29 +154,16 @@ async function runScan(): Promise<void> {
|
|||||||
original_language, orig_lang_source, needs_review,
|
original_language, orig_lang_source, needs_review,
|
||||||
imdb_id, tmdb_id, tvdb_id,
|
imdb_id, tmdb_id, tvdb_id,
|
||||||
scan_status, last_scanned_at
|
scan_status, last_scanned_at
|
||||||
) VALUES (
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'scanned', datetime('now'))
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
||||||
'scanned', datetime('now')
|
|
||||||
)
|
|
||||||
ON CONFLICT(jellyfin_id) DO UPDATE SET
|
ON CONFLICT(jellyfin_id) DO UPDATE SET
|
||||||
type = excluded.type,
|
type = excluded.type, name = excluded.name, series_name = excluded.series_name,
|
||||||
name = excluded.name,
|
series_jellyfin_id = excluded.series_jellyfin_id, season_number = excluded.season_number,
|
||||||
series_name = excluded.series_name,
|
episode_number = excluded.episode_number, year = excluded.year, file_path = excluded.file_path,
|
||||||
series_jellyfin_id = excluded.series_jellyfin_id,
|
file_size = excluded.file_size, container = excluded.container,
|
||||||
season_number = excluded.season_number,
|
original_language = excluded.original_language, orig_lang_source = excluded.orig_lang_source,
|
||||||
episode_number = excluded.episode_number,
|
needs_review = excluded.needs_review, imdb_id = excluded.imdb_id,
|
||||||
year = excluded.year,
|
tmdb_id = excluded.tmdb_id, tvdb_id = excluded.tvdb_id,
|
||||||
file_path = excluded.file_path,
|
scan_status = 'scanned', last_scanned_at = datetime('now')
|
||||||
file_size = excluded.file_size,
|
|
||||||
container = excluded.container,
|
|
||||||
original_language = excluded.original_language,
|
|
||||||
orig_lang_source = excluded.orig_lang_source,
|
|
||||||
needs_review = excluded.needs_review,
|
|
||||||
imdb_id = excluded.imdb_id,
|
|
||||||
tmdb_id = excluded.tmdb_id,
|
|
||||||
tvdb_id = excluded.tvdb_id,
|
|
||||||
scan_status = 'scanned',
|
|
||||||
last_scanned_at = datetime('now')
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const deleteStreams = db.prepare('DELETE FROM media_streams WHERE item_id = ?');
|
const 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
|
channels, channel_layout, bit_rate, sample_rate
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const upsertPlan = db.prepare(`
|
const upsertPlan = db.prepare(`
|
||||||
INSERT INTO review_plans (item_id, status, is_noop, notes)
|
INSERT INTO review_plans (item_id, status, is_noop, notes)
|
||||||
VALUES (?, 'pending', ?, ?)
|
VALUES (?, 'pending', ?, ?)
|
||||||
ON CONFLICT(item_id) DO UPDATE SET
|
ON CONFLICT(item_id) DO UPDATE SET is_noop = excluded.is_noop, notes = excluded.notes
|
||||||
is_noop = excluded.is_noop,
|
|
||||||
notes = excluded.notes
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const upsertDecision = db.prepare(`
|
const upsertDecision = db.prepare(`
|
||||||
INSERT INTO stream_decisions (plan_id, stream_id, action, target_index)
|
INSERT INTO stream_decisions (plan_id, stream_id, action, target_index)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
ON CONFLICT(plan_id, stream_id) DO UPDATE SET
|
ON CONFLICT(plan_id, stream_id) DO UPDATE SET action = excluded.action, target_index = excluded.target_index
|
||||||
action = excluded.action,
|
|
||||||
target_index = excluded.target_index
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const getItemByJellyfinId = db.prepare('SELECT id FROM media_items WHERE jellyfin_id = ?');
|
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 getPlanByItemId = db.prepare('SELECT id FROM review_plans WHERE item_id = ?');
|
||||||
const getStreamsByItemId = db.prepare('SELECT * FROM media_streams 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 (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++;
|
processed++;
|
||||||
emitSse('progress', {
|
emitSse('progress', { scanned: processed, total, current_item: jellyfinItem.Name, errors, running: true });
|
||||||
scanned,
|
|
||||||
total,
|
|
||||||
current_item: jellyfinItem.Name,
|
|
||||||
errors,
|
|
||||||
running: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const providerIds = jellyfinItem.ProviderIds ?? {};
|
const providerIds = jellyfinItem.ProviderIds ?? {};
|
||||||
@@ -222,120 +206,56 @@ async function runScan(): Promise<void> {
|
|||||||
const tmdbId = providerIds['Tmdb'] ?? null;
|
const tmdbId = providerIds['Tmdb'] ?? null;
|
||||||
const tvdbId = providerIds['Tvdb'] ?? null;
|
const tvdbId = providerIds['Tvdb'] ?? null;
|
||||||
|
|
||||||
// Determine original language
|
|
||||||
let origLang: string | null = extractOriginalLanguage(jellyfinItem);
|
let origLang: string | null = extractOriginalLanguage(jellyfinItem);
|
||||||
let origLangSource: string = 'jellyfin';
|
let origLangSource = 'jellyfin';
|
||||||
let needsReview = origLang ? 0 : 1;
|
let needsReview = origLang ? 0 : 1;
|
||||||
|
|
||||||
// Cross-check with Radarr (movies)
|
|
||||||
if (jellyfinItem.Type === 'Movie' && radarrEnabled && (tmdbId || imdbId)) {
|
if (jellyfinItem.Type === 'Movie' && radarrEnabled && (tmdbId || imdbId)) {
|
||||||
const radarrLanguage = await radarrLang(
|
const lang = await radarrLang({ url: cfg.radarr_url, apiKey: cfg.radarr_api_key }, { tmdbId: tmdbId ?? undefined, imdbId: imdbId ?? undefined });
|
||||||
{ url: cfg.radarr_url, apiKey: cfg.radarr_api_key },
|
if (lang) { if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; origLang = lang; origLangSource = 'radarr'; }
|
||||||
{ tmdbId: tmdbId ?? undefined, imdbId: imdbId ?? undefined }
|
|
||||||
);
|
|
||||||
if (radarrLanguage) {
|
|
||||||
if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(radarrLanguage)) {
|
|
||||||
// Conflict: prefer Radarr, flag for review
|
|
||||||
needsReview = 1;
|
|
||||||
}
|
|
||||||
origLang = radarrLanguage;
|
|
||||||
origLangSource = 'radarr';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-check with Sonarr (episodes)
|
|
||||||
if (jellyfinItem.Type === 'Episode' && sonarrEnabled && tvdbId) {
|
if (jellyfinItem.Type === 'Episode' && sonarrEnabled && tvdbId) {
|
||||||
const sonarrLanguage = await sonarrLang(
|
const lang = await sonarrLang({ url: cfg.sonarr_url, apiKey: cfg.sonarr_api_key }, tvdbId);
|
||||||
{ url: cfg.sonarr_url, apiKey: cfg.sonarr_api_key },
|
if (lang) { if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(lang)) needsReview = 1; origLang = lang; origLangSource = 'sonarr'; }
|
||||||
tvdbId
|
|
||||||
);
|
|
||||||
if (sonarrLanguage) {
|
|
||||||
if (origLang && normalizeLanguage(origLang) !== normalizeLanguage(sonarrLanguage)) {
|
|
||||||
needsReview = 1;
|
|
||||||
}
|
|
||||||
origLang = sonarrLanguage;
|
|
||||||
origLangSource = 'sonarr';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert item
|
|
||||||
upsertItem.run(
|
upsertItem.run(
|
||||||
jellyfinItem.Id,
|
jellyfinItem.Id, jellyfinItem.Type === 'Episode' ? 'Episode' : 'Movie',
|
||||||
jellyfinItem.Type === 'Episode' ? 'Episode' : 'Movie',
|
jellyfinItem.Name, jellyfinItem.SeriesName ?? null, jellyfinItem.SeriesId ?? null,
|
||||||
jellyfinItem.Name,
|
jellyfinItem.ParentIndexNumber ?? null, jellyfinItem.IndexNumber ?? null,
|
||||||
jellyfinItem.SeriesName ?? null,
|
jellyfinItem.ProductionYear ?? null, jellyfinItem.Path, jellyfinItem.Size ?? null,
|
||||||
jellyfinItem.SeriesId ?? null,
|
jellyfinItem.Container ?? null, origLang, origLangSource, needsReview,
|
||||||
jellyfinItem.ParentIndexNumber ?? null,
|
imdbId, tmdbId, tvdbId
|
||||||
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 itemRow = getItemByJellyfinId.get(jellyfinItem.Id) as { id: number };
|
||||||
const itemId = itemRow.id;
|
const itemId = itemRow.id;
|
||||||
|
|
||||||
// Upsert streams
|
|
||||||
deleteStreams.run(itemId);
|
deleteStreams.run(itemId);
|
||||||
for (const jStream of jellyfinItem.MediaStreams ?? []) {
|
for (const jStream of jellyfinItem.MediaStreams ?? []) {
|
||||||
|
if (jStream.IsExternal) continue; // skip external subs — not embedded in container
|
||||||
const s = mapStream(jStream);
|
const s = mapStream(jStream);
|
||||||
insertStream.run(
|
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);
|
||||||
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 streams = getStreamsByItemId.all(itemId) as MediaStream[];
|
||||||
const analysis = analyzeItem(
|
const analysis = analyzeItem({ original_language: origLang, needs_review: needsReview }, streams, { subtitleLanguages });
|
||||||
{ original_language: origLang, needs_review: needsReview },
|
|
||||||
streams,
|
|
||||||
{ subtitleLanguages }
|
|
||||||
);
|
|
||||||
|
|
||||||
upsertPlan.run(itemId, analysis.is_noop ? 1 : 0, analysis.notes);
|
upsertPlan.run(itemId, analysis.is_noop ? 1 : 0, analysis.notes);
|
||||||
|
|
||||||
const planRow = getPlanByItemId.get(itemId) as { id: number };
|
const planRow = getPlanByItemId.get(itemId) as { id: number };
|
||||||
const planId = planRow.id;
|
for (const dec of analysis.decisions) upsertDecision.run(planRow.id, dec.stream_id, dec.action, dec.target_index);
|
||||||
|
|
||||||
for (const dec of analysis.decisions) {
|
|
||||||
upsertDecision.run(planId, dec.stream_id, dec.action, dec.target_index);
|
|
||||||
}
|
|
||||||
|
|
||||||
emitSse('log', { name: jellyfinItem.Name, type: jellyfinItem.Type, status: 'scanned' });
|
emitSse('log', { name: jellyfinItem.Name, type: jellyfinItem.Type, status: 'scanned' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors++;
|
errors++;
|
||||||
console.error(`Error scanning ${jellyfinItem.Name}:`, err);
|
console.error(`Error scanning ${jellyfinItem.Name}:`, err);
|
||||||
try {
|
try { db.prepare("UPDATE media_items SET scan_status = 'error', scan_error = ? WHERE jellyfin_id = ?").run(String(err), jellyfinItem.Id); } catch { /* ignore */ }
|
||||||
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' });
|
emitSse('log', { name: jellyfinItem.Name, type: jellyfinItem.Type, status: 'error' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setConfig('scan_running', '0');
|
setConfig('scan_running', '0');
|
||||||
emitSse('complete', { scanned, total, errors });
|
emitSse('complete', { scanned: processed, total, errors });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default app;
|
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,
|
stream_id INTEGER NOT NULL REFERENCES media_streams(id) ON DELETE CASCADE,
|
||||||
action TEXT NOT NULL,
|
action TEXT NOT NULL,
|
||||||
target_index INTEGER,
|
target_index INTEGER,
|
||||||
|
custom_title TEXT,
|
||||||
UNIQUE(plan_id, stream_id)
|
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 (
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
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',
|
sonarr_enabled: '0',
|
||||||
subtitle_languages: JSON.stringify(['eng', 'deu', 'spa']),
|
subtitle_languages: JSON.stringify(['eng', 'deu', 'spa']),
|
||||||
scan_running: '0',
|
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;
|
item_id: number;
|
||||||
status: 'pending' | 'approved' | 'skipped' | 'done' | 'error';
|
status: 'pending' | 'approved' | 'skipped' | 'done' | 'error';
|
||||||
is_noop: number;
|
is_noop: number;
|
||||||
|
subs_extracted: number;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
reviewed_at: string | null;
|
reviewed_at: string | null;
|
||||||
created_at: string;
|
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 {
|
export interface StreamDecision {
|
||||||
id: number;
|
id: number;
|
||||||
plan_id: number;
|
plan_id: number;
|
||||||
stream_id: number;
|
stream_id: number;
|
||||||
action: 'keep' | 'remove';
|
action: 'keep' | 'remove';
|
||||||
target_index: number | null;
|
target_index: number | null;
|
||||||
|
custom_title: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Job {
|
export interface Job {
|
||||||
@@ -97,6 +111,7 @@ export interface StreamWithDecision extends MediaStream {
|
|||||||
|
|
||||||
export interface PlanResult {
|
export interface PlanResult {
|
||||||
is_noop: boolean;
|
is_noop: boolean;
|
||||||
|
has_subs: boolean;
|
||||||
decisions: Array<{ stream_id: number; action: 'keep' | 'remove'; target_index: number | null }>;
|
decisions: Array<{ stream_id: number; action: 'keep' | 'remove'; target_index: number | null }>;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
}
|
}
|
||||||
@@ -113,6 +128,7 @@ export interface JellyfinMediaStream {
|
|||||||
IsDefault?: boolean;
|
IsDefault?: boolean;
|
||||||
IsForced?: boolean;
|
IsForced?: boolean;
|
||||||
IsHearingImpaired?: boolean;
|
IsHearingImpaired?: boolean;
|
||||||
|
IsExternal?: boolean;
|
||||||
Channels?: number;
|
Channels?: number;
|
||||||
ChannelLayout?: string;
|
ChannelLayout?: string;
|
||||||
BitRate?: number;
|
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",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "hono/jsx",
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"types": ["bun-types"],
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
"lib": ["ESNext"]
|
"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