diff --git a/.gitignore b/.gitignore index cc5c352d..e8fff945 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,6 @@ stream-proxy/target/ .claude/ sv-agentation CLAUDE.md + +# Runtime data (live DB, backups) — never commit +data/ diff --git a/.npmrc b/.npmrc index 6c59086d..d0996852 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1 @@ -enable-pre-post-scripts=true +verify-deps-before-run=false diff --git a/Dockerfile b/Dockerfile index bf370547..1f0dd227 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ # tracking doesn't always detect source changes when a cached dummy build is # replaced with real source, producing a binary from the dummy. Accepting a # longer first-build time in exchange for a correct build every time. -FROM rust:1.85-alpine AS rust-build +FROM rust:1.88-alpine AS rust-build RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static WORKDIR /stream-proxy COPY stream-proxy/Cargo.toml stream-proxy/Cargo.lock ./ @@ -18,7 +18,7 @@ RUN cargo build --release FROM node:22-alpine AS deps WORKDIR /app -RUN corepack enable && corepack prepare pnpm@latest --activate +RUN corepack enable && corepack prepare pnpm@11.1.2 --activate # python3 + build tools: better-sqlite3 tries prebuild-install first but falls # back to node-gyp compilation when the prebuilt binary for this exact Node # version isn't available. Without these, the fallback fails with "gyp ERR! @@ -33,14 +33,14 @@ RUN pnpm install --frozen-lockfile FROM node:22-alpine AS build WORKDIR /app -RUN corepack enable && corepack prepare pnpm@latest --activate +RUN corepack enable && corepack prepare pnpm@11.1.2 --activate COPY --from=deps /app/node_modules ./node_modules COPY . . RUN pnpm build FROM node:22-alpine AS runtime WORKDIR /app -RUN corepack enable && corepack prepare pnpm@latest --activate +RUN corepack enable && corepack prepare pnpm@11.1.2 --activate # Same reason as deps stage — better-sqlite3 may fall back to node-gyp. RUN apk add --no-cache python3 make g++ diff --git a/README.md b/README.md index 561bf725..6f67eb2c 100644 --- a/README.md +++ b/README.md @@ -43,16 +43,11 @@ Self-hosters running multiple media services who want: | Service | Status | What it provides | |---------|--------|-----------------| -| **Jellyfin** | ✅ stable | Media server — movies, shows, music, live TV | -| **Plex** | ✅ beta | Media server — movies, shows, music (playback path stabilized in v0.1.0-beta.2) | -| **Invidious** | ✅ stable | Privacy-respecting YouTube alternative (transcode pipe via Rust stream-proxy) | -| **Calibre-Web** | ✅ beta | Book library — OPDS browse, search, formats, in-browser reader. UI polish ongoing | -| **RomM** | ✅ stable | Retro game ROM management with in-browser emulation | -| **Overseerr / Seerr** | ✅ stable | Media requests and TMDB-powered discovery | -| **Radarr / Sonarr / Lidarr** | ✅ stable | Calendar, queue, quality profiles | -| **Bazarr** | ✅ stable | Subtitle management, sync, translation | -| **Prowlarr** | ✅ stable | Indexer management and stats | -| **StreamyStats** | ✅ stable | ML-powered recommendations and analytics | +| **Jellyfin** | ✅ working | Movies & shows — library, recently-added, and transcoded playback end-to-end via the Rust stream-proxy | +| **Invidious** | ⚠️ playback only | Privacy-respecting YouTube — playback works end-to-end; browse/search not yet wired, so it doesn't surface on the home page yet | +| **Plex** | 🚧 unwired | Adapter is implemented but has no config path in phase-0 (no env/DB/UI to add a Plex server), so it's not reachable yet | + +> **Phase-0 is a clean re-implementation, not a port.** It ships exactly these three media-source adapters plus the streaming core, the home feed, and a bare playback test page. The earlier project's broader service list (Overseerr/Seerr, Radarr/Sonarr/Lidarr, Bazarr, Prowlarr, Calibre-Web, RomM, StreamyStats) is **not** part of this build. Those will be reintroduced only when actually built and verified — this table tracks reality, not intent. New adapters can be added by contributors without modifying any existing code. See [CONTRIBUTING.md](CONTRIBUTING.md) for the adapter development guide. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8f39d7a6..ee795b5a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -16,25 +16,29 @@ Updated as work progresses. Each item links to its GitHub issue and/or spec/plan --- -## Milestone 0: Foundation (DONE) +## Phase-0 Rebuild — Actual Status + +This is a **clean re-implementation**. The current build ships only what this table +lists. Everything below "Milestone 1" describes the **pre-rebuild project** and is kept +only as historical / feature reference — it does **not** reflect what runs today. + +| Status | Item | +|--------|------| +| ✅ done | Jellyfin adapter — library, recently-added, transcoded playback (verified end-to-end against live Jellyfin) | +| ✅ done | Streaming core — negotiate + PASETO grants + Rust stream-proxy (real decodable bytes, SSRF-guarded, fail-closed) | +| ✅ done | Home feed — real Jellyfin content, backend-agnostic rows, real library-type filters (no fake chrome) | +| ✅ done | Auth — Authentik SSO passthrough (no app-owned login screens) | +| ✅ done | Bare playback test page (`/test-play`) — the sanctioned "dumb streaming page" | +| ⚠️ partial | Invidious adapter — playback works end-to-end; browse/search/listing NOT implemented, so it doesn't surface on the home yet | +| 🚧 unwired | Plex adapter — fully implemented but no config path (no env/DB/UI), so it's unreachable | +| ❌ not built | Search — `adapter.search` exists but is unwired; no `/api/search`, no search UI | +| ❌ not built | Subtitle delivery — negotiate advertises track URLs, but `/api/subtitles/*` route is missing (they 404) | +| ❌ not built | Media detail pages; now-playing/sessions UI | +| ❌ not built | Request management / Radarr / Sonarr / Overseerr — **no code path exists; do not advertise as available** | +| ❌ not built | Analytics / stats / admin dashboard | +| ❌ not built | Calibre-Web, RomM, StreamyStats, Bazarr, Prowlarr adapters | -Everything needed to run Nexus as a functional media platform. - -| Status | Item | Issue | Spec/Plan | -|--------|------|-------|-----------| -| done | Core adapters (Jellyfin, Radarr, Sonarr, Lidarr, Overseerr, Prowlarr, Bazarr) | — | — | -| done | Per-user auth with service account linking | — | — | -| done | Homepage with personalized recommendations | — | [spec](docs/superpowers/specs/2026-03-11-personalized-homepage-design.md) | -| done | Media detail pages with cast, similar, seasons | — | — | -| done | Search across all services | — | — | -| done | Request management (Overseerr) | — | [spec](docs/superpowers/specs/2026-03-12-requests-page-fixes-design.md) | -| done | Analytics engine + stats | — | [spec](docs/superpowers/specs/2026-03-13-tracking-system-rebuild-design.md) | -| done | Admin dashboard (sessions, health, requests) | — | — | -| done | Invidious adapter (privacy video) | — | — | -| done | Calibre-Web adapter (books) | — | — | -| done | RomM adapter (retro games + EmulatorJS) | — | — | -| done | StreamyStats adapter (ML recommendations) | — | — | -| done | Bazarr adapter (subtitle enrichment) | — | — | +--- ## Milestone 1: Polish & Beta (DONE) diff --git a/nexus-collapsed.png b/nexus-collapsed.png new file mode 100644 index 00000000..43908a31 Binary files /dev/null and b/nexus-collapsed.png differ diff --git a/nexus-home-live.png b/nexus-home-live.png new file mode 100644 index 00000000..ff6c2271 Binary files /dev/null and b/nexus-home-live.png differ diff --git a/package.json b/package.json index d58ca64a..49b420fb 100644 --- a/package.json +++ b/package.json @@ -39,12 +39,16 @@ "tailwindcss": "^4.1.0", "typescript": "^5.9.3", "vite": "^7.3.1", + "vite-plugin-wasm": "^3.6.0", "vitest": "^4.1.2" }, "dependencies": { "@fontsource-variable/dm-sans": "^5.2.8", + "@fontsource-variable/geist": "^5.2.9", + "@fontsource-variable/geist-mono": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/playfair-display": "^5.2.8", + "better-auth": "^1.6.20", "better-sqlite3": "^11.10.0", "chart.js": "^4.5.1", "dashjs": "^5.1.1", @@ -53,6 +57,8 @@ "hls.js": "^1.6.15", "lru-cache": "^11.3.5", "lucide-svelte": "0.469.0", + "nucleo-matcher-wasm": "^0.4.0", + "paseto-ts": "^2.0.6", "pdfjs-dist": "5.5.207", "undici": "^8.1.0", "ws": "^8.19.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfaa5dd5..5a49ed84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,12 +11,21 @@ importers: '@fontsource-variable/dm-sans': specifier: ^5.2.8 version: 5.2.8 + '@fontsource-variable/geist': + specifier: ^5.2.9 + version: 5.2.9 + '@fontsource-variable/geist-mono': + specifier: ^5.2.8 + version: 5.2.8 '@fontsource-variable/jetbrains-mono': specifier: ^5.2.8 version: 5.2.8 '@fontsource-variable/playfair-display': specifier: ^5.2.8 version: 5.2.8 + better-auth: + specifier: ^1.6.20 + version: 1.6.20(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.6)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.6)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)))(better-sqlite3@11.10.0)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(kysely@0.29.2))(svelte@5.53.6)(vitest@4.1.2(@types/node@25.3.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1))) better-sqlite3: specifier: ^11.10.0 version: 11.10.0 @@ -28,7 +37,7 @@ importers: version: 5.1.1(@svta/cml-cta@1.0.1(@svta/cml-structured-field-values@1.0.1(@svta/cml-utils@1.0.1))(@svta/cml-utils@1.0.1))(@svta/cml-structured-field-values@1.0.1(@svta/cml-utils@1.0.1))(@svta/cml-utils@1.0.1) drizzle-orm: specifier: ^0.44.0 - version: 0.44.7(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0) + version: 0.44.7(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(kysely@0.29.2) fast-xml-parser: specifier: ^5.5.12 version: 5.5.12 @@ -41,6 +50,12 @@ importers: lucide-svelte: specifier: 0.469.0 version: 0.469.0(svelte@5.53.6) + nucleo-matcher-wasm: + specifier: ^0.4.0 + version: 0.4.0 + paseto-ts: + specifier: ^2.0.6 + version: 2.0.6 pdfjs-dist: specifier: 5.5.207 version: 5.5.207 @@ -105,12 +120,94 @@ importers: vite: specifier: ^7.3.1 version: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1) + vite-plugin-wasm: + specifier: ^3.6.0 + version: 3.6.0(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)) vitest: specifier: ^4.1.2 version: 4.1.2(@types/node@25.3.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)) packages: + '@better-auth/core@1.6.20': + resolution: {integrity: sha512-y73I1xNXuNYiHBFduWGRcJ2ro2rNuVDEYkgVMJtIaRXtbosdXHs9gfyQrHecgeHMHKx1SYSBT/CExak0vVMTng==} + peerDependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@cloudflare/workers-types': '>=4' + '@opentelemetry/api': ^1.9.0 + better-call: 1.3.6 + jose: ^6.1.0 + kysely: ^0.28.5 || ^0.29.0 + nanostores: ^1.0.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + '@opentelemetry/api': + optional: true + + '@better-auth/drizzle-adapter@1.6.20': + resolution: {integrity: sha512-hJHfCdAiZrC7EmZAt3NAiGgcNo9Y5Qz3PLL+a9rODXaAJGCMvzUJniqef9wHuJBwU0SWW+2f4wXe8xQmaC/IKQ==} + peerDependencies: + '@better-auth/core': ^1.6.20 + '@better-auth/utils': 0.4.2 + drizzle-orm: ^0.45.2 + peerDependenciesMeta: + drizzle-orm: + optional: true + + '@better-auth/kysely-adapter@1.6.20': + resolution: {integrity: sha512-Uvpmgbx5y8JqXroVanNzDdKzOl3HojoTz+/X6MR6zOUr25IzlYz660mjnu0rxKiIF55kD3CroqFsDzjNUw7ERw==} + peerDependencies: + '@better-auth/core': ^1.6.20 + '@better-auth/utils': 0.4.2 + kysely: ^0.28.17 || ^0.29.0 + peerDependenciesMeta: + kysely: + optional: true + + '@better-auth/memory-adapter@1.6.20': + resolution: {integrity: sha512-J5Ni0LlFijbzXlwu2rFHaD8zEFocmajyzWkRnHsq8LhV/Dk4iWQwwnqzLrPoDQEj8roECAUF03hrIeMzqWRqJQ==} + peerDependencies: + '@better-auth/core': ^1.6.20 + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.6.20': + resolution: {integrity: sha512-ClDBJf6h4g85WJswxwQwxLaiyRU67Gmz/uaIf19tY1gqlLJDykSGjmqRNSBMG5rWABNzcNqbO4KG31rYUldbIw==} + peerDependencies: + '@better-auth/core': ^1.6.20 + '@better-auth/utils': 0.4.2 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true + + '@better-auth/prisma-adapter@1.6.20': + resolution: {integrity: sha512-WhYdhSGuVSfu1peCSf2snmmVzfWjRaEvbSrsNCusiwGE9l94HlES4mjSPM48fed24hL7yg4j1dYK/yjEt87FpQ==} + peerDependencies: + '@better-auth/core': ^1.6.20 + '@better-auth/utils': 0.4.2 + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@prisma/client': + optional: true + prisma: + optional: true + + '@better-auth/telemetry@1.6.20': + resolution: {integrity: sha512-3BhbY3naQDERvdJvJ7fGszVY6rpsVfc6c9uyBVZlC1coVEF/rkM0rIcjtMVI1GUH7vWy1wjR6qF5vQnMun3XNQ==} + peerDependencies: + '@better-auth/core': ^1.6.20 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + + '@better-auth/utils@0.4.2': + resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} + + '@better-fetch/fetch@1.3.1': + resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -569,6 +666,12 @@ packages: '@fontsource-variable/dm-sans@5.2.8': resolution: {integrity: sha512-AxkvMTvNWgfrmlyjiV05vlHYJa+nRQCf1EfvIrQAPBpFJW0O9VTz7oAFr9S3lvbWdmnFoBk7yFqQL86u64nl2g==} + '@fontsource-variable/geist-mono@5.2.8': + resolution: {integrity: sha512-KI5bj+hkkRiHttYHmccotUZ80ZuZyai+RwI1d7UId0clkx/jXxlo8qYK8j54WzmpBjtMoEMPyllV7faDcj+6RA==} + + '@fontsource-variable/geist@5.2.9': + resolution: {integrity: sha512-TP+QSBG3wxKGPE33CbMy/L0Nu3qvJ6Fy81Yc4LnQ95xH+i+cfEp8fyU8/kfV14YwszxIFPhnoMTbjL71waVpyQ==} + '@fontsource-variable/jetbrains-mono@5.2.8': resolution: {integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==} @@ -623,30 +726,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@0.1.97': resolution: {integrity: sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@0.1.97': resolution: {integrity: sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@0.1.97': resolution: {integrity: sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-musl@0.1.97': resolution: {integrity: sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/canvas-win32-arm64-msvc@0.1.97': resolution: {integrity: sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==} @@ -664,6 +772,18 @@ packages: resolution: {integrity: sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==} engines: {node: '>= 10'} + '@noble/ciphers@2.2.0': + resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + '@playwright/test@1.58.2': resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} engines: {node: '>=18'} @@ -742,66 +862,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -833,6 +966,36 @@ packages: cpu: [x64] os: [win32] + '@stablelib/binary@2.0.1': + resolution: {integrity: sha512-U9iAO8lXgEDONsA0zPPSgcf3HUBNAqHiJmSHgZz62OvC3Hi2Bhc5kTnQ3S1/L+sthDTHtCMhcEiklmIly6uQ3w==} + + '@stablelib/blake2b@2.0.1': + resolution: {integrity: sha512-kBN4i9FHpkTEVrHwKtb1ZjI6QwyoK8emY9TaLcHHFSZkw2FN9Td8Js9mU2U10q+EZ3Mp6z78QgdPDwJXbc3Dtw==} + + '@stablelib/chacha@2.0.1': + resolution: {integrity: sha512-lS1FqtNqofxe2vLkRsLli2m3x/XanUyAYRphLhdHumKeIsLbjbCXdCq3Pf/eWiO7G3QlSG5ViqnoVjktzfLWMg==} + + '@stablelib/ed25519@2.1.0': + resolution: {integrity: sha512-8GLWoJur9nJiErABKHs5MjceSiYJJ2n8QP9k8Q5x6GWU2y5oAQ6qzPh8kCgQy5aHlUxcmRA1frQ5Gu2bHoIDPw==} + + '@stablelib/hash@2.0.0': + resolution: {integrity: sha512-u3WPSqGido8lwJuMcrBgM5K54LrPGhkWAdtsyccf7dGsLixAZUds77zOAbu7bvKPwQlmoByH0txBi5rTmEKuHg==} + + '@stablelib/int@2.0.1': + resolution: {integrity: sha512-Ht63fQp3wz/F8U4AlXEPb7hfJOIILs8Lq55jgtD7KueWtyjhVuzcsGLSTAWtZs3XJDZYdF1WcSKn+kBtbzupww==} + + '@stablelib/random@2.0.1': + resolution: {integrity: sha512-W6GAtXEEs7r+dSbuBsvoFmlyL3gLxle41tQkjKu17dDWtDdjhVUbtRfRCQcCUeczwkgjQxMPopgwYEvxXtHXGw==} + + '@stablelib/sha512@2.0.1': + resolution: {integrity: sha512-DUNe5cbnoH3sSIN+MG04RvTCLXtkbyy/SnQxiNO+GgF/KSXkkUSlF6mUVvCUdZBZ2X3NgogR+tAvaRSn8wxnLw==} + + '@stablelib/wipe@2.0.1': + resolution: {integrity: sha512-1eU2K9EgOcV4qc9jcP6G72xxZxEm5PfeI5H55l08W95b4oRJaqhmlWRc4xZAm6IVSKhVNxMi66V67hCzzuMTAg==} + + '@stablelib/xchacha20@2.0.1': + resolution: {integrity: sha512-k55pNv7gIM4mUPU00+nJYTxKiUVNwAtsgrridC0aIU5cVbw9u6qP99x8ENu5eiwOEhZUNg+p3tTOLooCeAOJQA==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -977,24 +1140,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.1': resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.1': resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.1': resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.1': resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} @@ -1114,6 +1281,76 @@ packages: bcp-47@2.1.0: resolution: {integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==} + better-auth@1.6.20: + resolution: {integrity: sha512-fSpGHGRKiGRiYVd3QTQtuVZ8oxpiSe/7ip0Rpvt/Sy8zQbEbVKUPMOhE0gLXg+FjqTUsIo7582hxUYxtEcqUpA==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + better-sqlite3: ^12.0.0 + drizzle-kit: '>=0.31.4' + drizzle-orm: ^0.45.2 + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.3.6: + resolution: {integrity: sha512-no1jI+h6Bkxs1NVBo4rONbVIzsPjZ8IUu7IHaJBiFwVX1XEQGN8KpHots5fSWmXe9nNyLuLIcgx6WEUcE6EDaA==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + better-sqlite3@11.10.0: resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} @@ -1185,6 +1422,9 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1436,10 +1676,17 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + kysely@0.29.2: + resolution: {integrity: sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg==} + engines: {node: '>=22.0.0'} + lie@3.1.1: resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==} @@ -1478,24 +1725,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -1525,6 +1776,7 @@ packages: lucide-svelte@0.469.0: resolution: {integrity: sha512-PMIJ8jrFqVUsXJz4d1yfAQplaGhNOahwwkzbunha8DhpiD73xqX24n8dE1dPpUk3vcrdWVsHc1y/liHHotOnGQ==} + deprecated: Package deprecated. Please use @lucide/svelte instead. peerDependencies: svelte: ^3 || ^4 || ^5.0.0-next.42 @@ -1557,6 +1809,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanostores@1.3.0: + resolution: {integrity: sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==} + engines: {node: ^20.0.0 || >=22.0.0} + napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} @@ -1567,12 +1823,18 @@ packages: node-readable-to-web-readable-stream@0.4.2: resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==} + nucleo-matcher-wasm@0.4.0: + resolution: {integrity: sha512-bchYWvWPtsB5ovN18QB1aeq7i1cGshZ8ciNCY+EcCqjlvy0oiaiSD4p6AkoT7z+3p9OPAT4/he12MBAINPstdQ==} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + paseto-ts@2.0.6: + resolution: {integrity: sha512-Ul3AzoCcS4uA6jF+GW2zYUxZY78xsqCbfxdv2Khk/30hDxAzcvsWQjp4uW1XcbkrO9zNrPbdBv4HiXgJigWLVA==} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -1717,6 +1979,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rou3@0.7.12: + resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -1846,6 +2111,11 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite-plugin-wasm@3.6.0: + resolution: {integrity: sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==} + peerDependencies: + vite: ^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8 + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1957,6 +2227,59 @@ packages: snapshots: + '@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)': + dependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@opentelemetry/semantic-conventions': 1.41.1 + '@standard-schema/spec': 1.1.0 + better-call: 1.3.6(zod@4.3.6) + jose: 6.2.3 + kysely: 0.29.2 + nanostores: 1.3.0 + zod: 4.3.6 + + '@better-auth/drizzle-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.44.7(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(kysely@0.29.2))': + dependencies: + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 + optionalDependencies: + drizzle-orm: 0.44.7(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(kysely@0.29.2) + + '@better-auth/kysely-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)': + dependencies: + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 + optionalDependencies: + kysely: 0.29.2 + + '@better-auth/memory-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 + + '@better-auth/prisma-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 + + '@better-auth/telemetry@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + dependencies: + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + + '@better-auth/utils@0.4.2': + dependencies: + '@noble/hashes': 2.2.0 + + '@better-fetch/fetch@1.3.1': {} + '@drizzle-team/brocli@0.10.2': {} '@esbuild-kit/core-utils@3.3.2': @@ -2193,6 +2516,10 @@ snapshots: '@fontsource-variable/dm-sans@5.2.8': {} + '@fontsource-variable/geist-mono@5.2.8': {} + + '@fontsource-variable/geist@5.2.9': {} + '@fontsource-variable/jetbrains-mono@5.2.8': {} '@fontsource-variable/playfair-display@5.2.8': {} @@ -2266,6 +2593,12 @@ snapshots: '@napi-rs/canvas-win32-x64-msvc': 0.1.97 optional: true + '@noble/ciphers@2.2.0': {} + + '@noble/hashes@2.2.0': {} + + '@opentelemetry/semantic-conventions@1.41.1': {} + '@playwright/test@1.58.2': dependencies: playwright: 1.58.2 @@ -2383,6 +2716,50 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true + '@stablelib/binary@2.0.1': + dependencies: + '@stablelib/int': 2.0.1 + + '@stablelib/blake2b@2.0.1': + dependencies: + '@stablelib/binary': 2.0.1 + '@stablelib/hash': 2.0.0 + '@stablelib/wipe': 2.0.1 + + '@stablelib/chacha@2.0.1': + dependencies: + '@stablelib/binary': 2.0.1 + '@stablelib/wipe': 2.0.1 + + '@stablelib/ed25519@2.1.0': + dependencies: + '@stablelib/random': 2.0.1 + '@stablelib/sha512': 2.0.1 + '@stablelib/wipe': 2.0.1 + + '@stablelib/hash@2.0.0': {} + + '@stablelib/int@2.0.1': {} + + '@stablelib/random@2.0.1': + dependencies: + '@stablelib/binary': 2.0.1 + '@stablelib/wipe': 2.0.1 + + '@stablelib/sha512@2.0.1': + dependencies: + '@stablelib/binary': 2.0.1 + '@stablelib/hash': 2.0.0 + '@stablelib/wipe': 2.0.1 + + '@stablelib/wipe@2.0.1': {} + + '@stablelib/xchacha20@2.0.1': + dependencies: + '@stablelib/binary': 2.0.1 + '@stablelib/chacha': 2.0.1 + '@stablelib/wipe': 2.0.1 + '@standard-schema/spec@1.1.0': {} '@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)': @@ -2635,6 +3012,45 @@ snapshots: is-alphanumerical: 2.0.1 is-decimal: 2.0.1 + better-auth@1.6.20(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.6)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.6)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)))(better-sqlite3@11.10.0)(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(kysely@0.29.2))(svelte@5.53.6)(vitest@4.1.2(@types/node@25.3.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1))): + dependencies: + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/drizzle-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.44.7(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(kysely@0.29.2)) + '@better-auth/kysely-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2) + '@better-auth/memory-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@noble/ciphers': 2.2.0 + '@noble/hashes': 2.2.0 + better-call: 1.3.6(zod@4.3.6) + defu: 6.1.7 + jose: 6.2.3 + kysely: 0.29.2 + nanostores: 1.3.0 + zod: 4.3.6 + optionalDependencies: + '@sveltejs/kit': 2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.6)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.6)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)) + better-sqlite3: 11.10.0 + drizzle-kit: 0.31.9 + drizzle-orm: 0.44.7(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(kysely@0.29.2) + svelte: 5.53.6 + vitest: 4.1.2(@types/node@25.3.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)) + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@opentelemetry/api' + + better-call@1.3.6(zod@4.3.6): + dependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + rou3: 0.7.12 + set-cookie-parser: 3.0.1 + optionalDependencies: + zod: 4.3.6 + better-sqlite3@11.10.0: dependencies: bindings: 1.5.0 @@ -2714,6 +3130,8 @@ snapshots: deepmerge@4.3.1: {} + defu@6.1.7: {} + detect-libc@2.1.2: {} devalue@5.6.3: {} @@ -2727,10 +3145,11 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.44.7(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0): + drizzle-orm@0.44.7(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(kysely@0.29.2): optionalDependencies: '@types/better-sqlite3': 7.6.13 better-sqlite3: 11.10.0 + kysely: 0.29.2 end-of-stream@1.4.5: dependencies: @@ -2930,8 +3349,12 @@ snapshots: jiti@2.6.1: {} + jose@6.2.3: {} + kleur@4.1.5: {} + kysely@0.29.2: {} + lie@3.1.1: dependencies: immediate: 3.0.6 @@ -3015,6 +3438,8 @@ snapshots: nanoid@3.3.11: {} + nanostores@1.3.0: {} + napi-build-utils@2.0.0: {} node-abi@3.87.0: @@ -3024,12 +3449,20 @@ snapshots: node-readable-to-web-readable-stream@0.4.2: optional: true + nucleo-matcher-wasm@0.4.0: {} + obug@2.1.1: {} once@1.4.0: dependencies: wrappy: 1.0.2 + paseto-ts@2.0.6: + dependencies: + '@stablelib/blake2b': 2.0.1 + '@stablelib/ed25519': 2.1.0 + '@stablelib/xchacha20': 2.0.1 + path-browserify@1.0.1: {} path-expression-matcher@1.5.0: {} @@ -3148,6 +3581,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 + rou3@0.7.12: {} + sade@1.8.1: dependencies: mri: 1.2.0 @@ -3276,6 +3711,10 @@ snapshots: util-deprecate@1.0.2: {} + vite-plugin-wasm@3.6.0(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)): + dependencies: + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1) + vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1): dependencies: esbuild: 0.27.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e4a4b5bb..e1ac8e06 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,7 @@ onlyBuiltDependencies: - better-sqlite3 + - esbuild +allowBuilds: + '@swc/core': set this to true or false + better-sqlite3: true + esbuild: true diff --git a/scripts/mint-golden-vector.mjs b/scripts/mint-golden-vector.mjs new file mode 100644 index 00000000..9bd9ba2b --- /dev/null +++ b/scripts/mint-golden-vector.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +/** + * Cross-language golden-vector minter (CI gate). Mints PASETO v4.local grant + * tokens with the Node `paseto-ts` side using a FIXED k4.local key, so the Rust + * `pasetors` side can prove byte-compatibility: verify + reconstruct the grant, + * and reject tampered / expired / wrong-user tokens. + * + * Emits a JSON fixture to stream-proxy/tests/fixtures/golden-vector.json that + * the Rust `cargo test golden_vector` consumes. Re-run to regenerate after any + * change to the mint/serialize logic: + * + * node scripts/mint-golden-vector.mjs + * + * The fixed key is TEST-ONLY (bytes 1..=32). Production keys come from + * NEXUS_STREAM_SECRET via HKDF (see src/lib/server/stream-grant.ts). + */ +import { writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { mintGrant, serializeImplicitAssertion } from '../src/lib/server/stream-grant.ts'; + +// Fixed 32-byte key → PASERK k4.local (bytes 1..=32). +const keyBytes = Buffer.alloc(32); +for (let i = 0; i < 32; i++) keyBytes[i] = i + 1; +function b64url(buf) { + return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} +const paserk = 'k4.local.' + b64url(keyBytes); + +const baseGrant = { + backend: 'jellyfin', + resource_ref: 'item123/src456', + allowed_hops: 'hopkey-abc', + user_id: 'user-001', + gen: 7, + hop_index: 0, +}; + +// 1. Valid token, far-future exp. +const validExp = '2099-01-01T00:00:00.000Z'; +const validToken = mintGrant({ ...baseGrant, exp: new Date(validExp) }, paserk, 'k0'); + +// 2. Expired token (past exp). +const expiredToken = mintGrant( + { ...baseGrant, exp: new Date('2000-01-01T00:00:00.000Z') }, + paserk, + 'k0', + true /* allowExpiredForTest */ +); + +// 3. Tampered: flip one base64 char in the valid token body. +function tamper(tok) { + const chars = tok.split(''); + // Flip a char in the encrypted-body region (after "v4.local."). + const i = 'v4.local.'.length + 5; + chars[i] = chars[i] === 'A' ? 'B' : 'A'; + return chars.join(''); +} +const tamperedToken = tamper(validToken); + +const fixture = { + comment: + 'Golden vector: Node paseto-ts mints, Rust pasetors must verify/reconstruct + reject tamper/expired/wrong-user. Fixed test key = bytes 1..=32.', + paserk_local_key: paserk, + key_bytes: Array.from(keyBytes), + expected_user_id: baseGrant.user_id, + expected_hop_index: baseGrant.hop_index, + expected_gen: baseGrant.gen, + expected_implicit_assertion: serializeImplicitAssertion({ + user_id: baseGrant.user_id, + hop_index: baseGrant.hop_index, + gen: baseGrant.gen, + }), + expected_claims: { + backend: baseGrant.backend, + resource_ref: baseGrant.resource_ref, + allowed_hops: baseGrant.allowed_hops, + gen: baseGrant.gen, + exp: validExp, + }, + valid_token: validToken, + expired_token: expiredToken, + tampered_token: tamperedToken, + wrong_user_id: 'user-EVIL', +}; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const out = path.resolve(__dirname, '../stream-proxy/tests/fixtures/golden-vector.json'); +writeFileSync(out, JSON.stringify(fixture, null, 2) + '\n'); +console.log('wrote', out); +console.log('valid token:', validToken.slice(0, 40) + '...'); diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 2f9f885e..15cc80ad 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,6 +1,9 @@ import { redirect, type Handle } from '@sveltejs/kit'; +import { building } from '$app/environment'; import { checkRateLimit, getClientIp } from '$lib/server/rate-limit'; -import { COOKIE_NAME, validateSession } from '$lib/server/auth'; +import { COOKIE_NAME, validateSession, getUserById, getUserByUsername, createUser, getUserCount } from '$lib/server/auth'; +import { randomBytes } from 'node:crypto'; +import { auth } from '$lib/server/auth/better-auth'; import { boot } from '$lib/server/boot'; import { NO_AUTH_PATHS, resolveRedirect } from '$lib/server/redirects'; @@ -8,11 +11,29 @@ import { NO_AUTH_PATHS, resolveRedirect } from '$lib/server/redirects'; // proxy, watchdog, lifecycle) are orchestrated in `$lib/server/boot`. Keep // hooks.server.ts focused on per-request middleware: rate limiting, session // loading, redirect dispatch, API gates, and security headers. -boot(); +// Guard against `vite build`: SvelteKit imports this module to bundle it, and +// boot() validates env (crypto secret) + spawns the proxy/pollers — none of +// which exist or are wanted at build time. Only boot on a real server start. +if (!building) boot(); export const handle: Handle = async ({ event, resolve }) => { const path = event.url.pathname; + // SECURITY: the Better Auth catch-all (/api/auth/[...all]) exposes every BA + // endpoint. Block the public sign-up surface — registration MUST go through the + // app's /register action, which enforces the registration_enabled setting and + // the approval flow (the raw BA endpoint bypasses both and creates active + // accounts). Also block /api/auth/admin/* defensively (the admin plugin is off, + // but this keeps the surface closed if it's ever re-enabled). The app's own + // /register calls auth.api.signUpEmail server-side, which does NOT route through + // this HTTP path, so it is unaffected. + if (path.startsWith('/api/auth/sign-up') || path.startsWith('/api/auth/admin')) { + return new Response(JSON.stringify({ error: 'Not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' } + }); + } + // Allowlisted pre-auth paths bypass rate limiting + the API state gate, // but still go through session loading and the redirect resolver — the // resolver now owns the per-entry-point lifecycle gates (#32), so we @@ -60,12 +81,50 @@ export const handle: Handle = async ({ event, resolve }) => { // Populate event.locals.user from the session cookie — this is the session // hook's job and stays here. Redirect rules and API gates both read from it. const token = event.cookies.get(COOKIE_NAME); - const user = validateSession(token); + let user = validateSession(token); + // Better Auth cutover: if there's no legacy session cookie, validate a Better + // Auth session and load the full user row by id, so the redirect resolver and + // API gate below keep operating on the existing user shape unchanged. Legacy + // and BA sessions coexist during the transition — neither locks the other out. + if (!user) { + try { + const ba = await auth.api.getSession({ headers: event.request.headers }); + if (ba?.user?.id) user = getUserById(ba.user.id) ?? null; + } catch { + // BA not configured (no secret) or no valid session — stay unauthenticated. + } + } + // Authentik SSO passthrough. Every request to this app arrives via the + // Authentik forward-auth outpost (Traefik `authentik` middleware), which + // authenticates the user and forwards X-authentik-username/email/groups. + // Authentik is the SOLE gate — there are no app-owned login screens — so we + // provision/find the app user from those headers. Guarded by NEXUS_TRUST_PROXY + // (set only on the proxied deployment). NOTE: the published :8585 is LAN- + // reachable, so a direct LAN caller could spoof these headers — lock down + // (proxy-only ingress / shared-secret header) before trusting beyond a test LAN. + if (!user && process.env.NEXUS_TRUST_PROXY && process.env.NEXUS_TRUST_PROXY !== '0') { + const akUser = event.request.headers.get('x-authentik-username'); + if (akUser) { + let row = getUserByUsername(akUser); + if (!row) { + const groups = event.request.headers.get('x-authentik-groups') ?? ''; + // First provisioned user is admin (fresh-install owner); also honor an + // explicit admin group from Authentik. + const isAdmin = getUserCount() === 0 || /\b(authentik admins|nexus-admins|admins)\b/i.test(groups); + const id = createUser(akUser, akUser, randomBytes(24).toString('hex'), isAdmin, { + authProvider: 'authentik', + status: 'active' + }); + row = getUserById(id); + } + user = row ?? null; + } + } if (user) { event.locals.user = { id: user.id, username: user.username, - displayName: user.displayName, + displayName: user.displayName ?? user.name ?? user.username, avatar: user.avatar ?? null, isAdmin: user.isAdmin, status: user.status === 'pending' ? 'pending' : 'active', diff --git a/src/lib/adapters/__tests__/invidious-playback.test.ts b/src/lib/adapters/__tests__/invidious-playback.test.ts deleted file mode 100644 index 21f3fc5e..00000000 --- a/src/lib/adapters/__tests__/invidious-playback.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -// src/lib/adapters/__tests__/invidious-playback.test.ts -import { describe, it, expect } from 'vitest'; -import { pickBestFormat } from '../invidious-playback'; -import type { BrowserCaps, PlaybackPlan } from '../playback'; - -const defaultCaps: BrowserCaps = { - videoCodecs: ['avc1.640028'], - audioCodecs: ['mp4a.40.2', 'opus'], - containers: ['mp4', 'webm'], -}; - -const sampleFormats = [ - { itag: '22', container: 'mp4', resolution: '720p', qualityLabel: '720p', encoding: 'h264', type: 'video/mp4', mimeType: 'video/mp4' }, - { itag: '18', container: 'mp4', resolution: '360p', qualityLabel: '360p', encoding: 'h264', type: 'video/mp4', mimeType: 'video/mp4' }, - { itag: '137', container: 'mp4', qualityLabel: '1080p', encoding: 'h264', type: 'video/mp4' }, -]; - -describe('pickBestFormat', () => { - it('picks highest resolution muxed format by default', () => { - const result = pickBestFormat(sampleFormats, defaultCaps, {}); - expect(result?.itag).toBe('22'); // 720p muxed - }); - - it('respects targetHeight cap', () => { - const plan: PlaybackPlan = { targetHeight: 360 }; - const result = pickBestFormat(sampleFormats, defaultCaps, plan); - expect(result?.itag).toBe('18'); // 360p muxed - }); - - it('returns null for empty format list', () => { - expect(pickBestFormat([], defaultCaps, {})).toBeNull(); - }); -}); diff --git a/src/lib/adapters/__tests__/jellyfin-playback.test.ts b/src/lib/adapters/__tests__/jellyfin-playback.test.ts deleted file mode 100644 index 03d1a8d2..00000000 --- a/src/lib/adapters/__tests__/jellyfin-playback.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -// src/lib/adapters/__tests__/jellyfin-playback.test.ts -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { - mapPlaybackInfoToSession, - derivePlaybackMode, - filterTextSubtitles, - filterImageSubtitles, - jellyfinNegotiatePlayback, -} from '../jellyfin-playback'; - -vi.mock('$lib/server/stream-proxy', () => ({ - createStreamSession: async () => null, -})); - -describe('derivePlaybackMode', () => { - it('returns direct-play when TranscodingUrl is absent and SupportsDirectPlay', () => { - expect(derivePlaybackMode({ SupportsDirectPlay: true, SupportsDirectStream: true })).toBe('direct-play'); - }); - it('returns direct-stream when TranscodingUrl is absent and SupportsDirectStream but not DirectPlay', () => { - expect(derivePlaybackMode({ SupportsDirectPlay: false, SupportsDirectStream: true })).toBe('direct-stream'); - }); - it('returns transcode when TranscodingUrl is present', () => { - expect(derivePlaybackMode({ SupportsDirectPlay: false, SupportsDirectStream: false, TranscodingUrl: '/Videos/abc/master.m3u8?x=1' })).toBe('transcode'); - }); -}); - -describe('filterTextSubtitles', () => { - const streams = [ - { Index: 1, Type: 'Subtitle', Codec: 'srt', DisplayTitle: 'English', Language: 'eng', IsExternal: true }, - { Index: 2, Type: 'Subtitle', Codec: 'ass', DisplayTitle: 'Japanese', Language: 'jpn', IsExternal: false }, - { Index: 3, Type: 'Subtitle', Codec: 'pgssub', DisplayTitle: 'French PGS', Language: 'fre', IsExternal: false }, - { Index: 4, Type: 'Audio', Codec: 'aac', DisplayTitle: 'English', Language: 'eng', IsExternal: false }, - ]; - - it('returns only text-based subtitle tracks', () => { - const result = filterTextSubtitles(streams); - expect(result).toHaveLength(2); - expect(result[0].name).toBe('English'); - expect(result[1].name).toBe('Japanese'); - }); -}); - -describe('filterImageSubtitles', () => { - const streams = [ - { Index: 1, Type: 'Subtitle', Codec: 'srt', DisplayTitle: 'English', Language: 'eng' }, - { Index: 3, Type: 'Subtitle', Codec: 'pgssub', DisplayTitle: 'French PGS', Language: 'fre' }, - { Index: 5, Type: 'Subtitle', Codec: 'dvdsub', DisplayTitle: 'German DVD', Language: 'deu' }, - ]; - - it('returns only image-based subtitle tracks', () => { - const result = filterImageSubtitles(streams); - expect(result).toHaveLength(2); - expect(result[0].codec).toBe('pgssub'); - expect(result[1].codec).toBe('dvdsub'); - }); -}); - -describe('jellyfinNegotiatePlayback body construction', () => { - let fetchSpy: any; - - beforeEach(() => { - fetchSpy = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - PlaySessionId: 'ps-123', - MediaSources: [ - { - Id: 'item-1', - ItemId: 'item-1', - SupportsDirectPlay: true, - SupportsDirectStream: true, - MediaStreams: [{ Type: 'Video', Height: 1080 }], - }, - ], - }), - }); - vi.stubGlobal('fetch', fetchSpy); - }); - - const config = { id: 'svc', url: 'https://jf.test', apiKey: 'k' } as any; - const item = { id: 'item-1', type: 'movie' }; - const caps = { videoCodecs: ['h264'], audioCodecs: ['aac'], containers: ['mp4'] } as any; - - it('maps audioTrackHint to AudioStreamIndex in the POST body (#14)', async () => { - await jellyfinNegotiatePlayback(config, undefined, item, { audioTrackHint: 3 }, caps); - const body = JSON.parse(fetchSpy.mock.calls[0][1].body); - expect(body.AudioStreamIndex).toBe(3); - }); - - it('maps subtitleTrackHint to SubtitleStreamIndex in the POST body (#14)', async () => { - await jellyfinNegotiatePlayback(config, undefined, item, { subtitleTrackHint: 5 }, caps); - const body = JSON.parse(fetchSpy.mock.calls[0][1].body); - expect(body.SubtitleStreamIndex).toBe(5); - }); - - it('burnSubIndex wins over subtitleTrackHint when both are provided', async () => { - await jellyfinNegotiatePlayback( - config, - undefined, - item, - { burnSubIndex: 9, subtitleTrackHint: 5 }, - caps - ); - const body = JSON.parse(fetchSpy.mock.calls[0][1].body); - expect(body.SubtitleStreamIndex).toBe(9); - }); - - it('forces transcode when audioTrackHint is present', async () => { - await jellyfinNegotiatePlayback(config, undefined, item, { audioTrackHint: 3 }, caps); - const body = JSON.parse(fetchSpy.mock.calls[0][1].body); - expect(body.EnableDirectPlay).toBe(false); - expect(body.EnableDirectStream).toBe(false); - }); - - it('forces transcode when subtitleTrackHint is present', async () => { - await jellyfinNegotiatePlayback(config, undefined, item, { subtitleTrackHint: 5 }, caps); - const body = JSON.parse(fetchSpy.mock.calls[0][1].body); - expect(body.EnableDirectPlay).toBe(false); - expect(body.EnableDirectStream).toBe(false); - }); - - it('leaves direct-play enabled when no quality/track overrides are set', async () => { - await jellyfinNegotiatePlayback(config, undefined, item, {}, caps); - const body = JSON.parse(fetchSpy.mock.calls[0][1].body); - expect(body.EnableDirectPlay).toBe(true); - expect(body.EnableDirectStream).toBe(true); - expect(body.AudioStreamIndex).toBeUndefined(); - expect(body.SubtitleStreamIndex).toBeUndefined(); - }); -}); diff --git a/src/lib/adapters/__tests__/jellyfin-profile.test.ts b/src/lib/adapters/__tests__/jellyfin-profile.test.ts deleted file mode 100644 index 7c64aa0b..00000000 --- a/src/lib/adapters/__tests__/jellyfin-profile.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -// src/lib/adapters/__tests__/jellyfin-profile.test.ts -import { describe, it, expect } from 'vitest'; -import { buildDeviceProfile } from '../jellyfin-profile'; -import type { BrowserCaps, PlaybackPlan } from '../playback'; - -const defaultCaps: BrowserCaps = { - videoCodecs: ['avc1.640028', 'hev1.1.6.L93.B0'], - audioCodecs: ['mp4a.40.2', 'opus'], - containers: ['mp4', 'webm'], -}; - -describe('buildDeviceProfile', () => { - it('returns a profile with DirectPlayProfiles and TranscodingProfiles', () => { - const profile = buildDeviceProfile(defaultCaps, {}); - expect(profile.Name).toBe('Nexus MSE Browser'); - expect(profile.DirectPlayProfiles).toBeDefined(); - expect(profile.DirectPlayProfiles.length).toBeGreaterThan(0); - expect(profile.TranscodingProfiles).toBeDefined(); - expect(profile.TranscodingProfiles.length).toBeGreaterThan(0); - }); - - it('includes HLS hack DirectPlayProfile', () => { - const profile = buildDeviceProfile(defaultCaps, {}); - const hlsProfile = profile.DirectPlayProfiles.find( - (p: any) => p.Container === 'hls' - ); - expect(hlsProfile).toBeDefined(); - expect(hlsProfile.Type).toBe('Video'); - }); - - it('does NOT include MKV in DirectPlayProfiles', () => { - const profile = buildDeviceProfile(defaultCaps, {}); - const mkvProfile = profile.DirectPlayProfiles.find( - (p: any) => p.Container?.includes('mkv') - ); - expect(mkvProfile).toBeUndefined(); - }); - - it('sets MaxStreamingBitrate from plan', () => { - const plan: PlaybackPlan = { maxBitrate: 4_000_000 }; - const profile = buildDeviceProfile(defaultCaps, plan); - expect(profile.MaxStreamingBitrate).toBe(4_000_000); - }); - - it('uses AAC for HLS transcoding audio (no Opus in fMP4)', () => { - const profile = buildDeviceProfile(defaultCaps, {}); - const hlsTx = profile.TranscodingProfiles.find( - (p: any) => p.Protocol === 'hls' && p.Container === 'mp4' - ); - expect(hlsTx).toBeDefined(); - expect(hlsTx.AudioCodec).toContain('aac'); - expect(hlsTx.AudioCodec).not.toContain('opus'); - }); -}); diff --git a/src/lib/adapters/__tests__/registry.test.ts b/src/lib/adapters/__tests__/registry.test.ts deleted file mode 100644 index 0cda90a8..00000000 --- a/src/lib/adapters/__tests__/registry.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { registry } from '../registry'; - -describe('AdapterRegistry', () => { - it('returns all registered adapters', () => { - const all = registry.all(); - expect(all.length).toBeGreaterThan(0); - }); - - it('every adapter has required fields', () => { - for (const adapter of registry.all()) { - expect(adapter.id).toBeTruthy(); - expect(adapter.displayName).toBeTruthy(); - expect(typeof adapter.defaultPort).toBe('number'); - expect(typeof adapter.ping).toBe('function'); - } - }); - - it('every adapter has color and abbreviation', () => { - for (const adapter of registry.all()) { - expect(adapter.color).toBeTruthy(); - expect(adapter.abbreviation).toBeTruthy(); - } - }); - - // ---- Capability metadata tests (will pass after Task 2 adds values) ---- - - it('library adapters have isLibrary set', () => { - const libraries = registry.all().filter((a) => a.isLibrary); - const libraryIds = libraries.map((a) => a.id).sort(); - expect(libraryIds).toEqual(['calibre', 'invidious', 'jellyfin', 'plex', 'romm']); - }); - - it('searchable adapters have isSearchable set', () => { - const searchable = registry.all().filter((a) => a.isSearchable); - expect(searchable.length).toBeGreaterThanOrEqual(7); - expect(searchable.find((a) => a.id === 'bazarr')).toBeUndefined(); - expect(searchable.find((a) => a.id === 'prowlarr')).toBeUndefined(); - }); - - it('enrichment-only adapters are flagged', () => { - const enrichmentOnly = registry.all().filter((a) => a.isEnrichmentOnly); - const ids = enrichmentOnly.map((a) => a.id).sort(); - expect(ids).toEqual(['bazarr', 'prowlarr']); - }); - - it('authVia resolves to a valid adapter', () => { - const withAuthVia = registry.all().filter((a) => a.authVia); - for (const adapter of withAuthVia) { - expect(registry.get(adapter.authVia!)).toBeDefined(); - } - }); - - it('searchPriority defaults to Infinity when not set', () => { - for (const adapter of registry.all()) { - const priority = adapter.searchPriority ?? Infinity; - expect(typeof priority).toBe('number'); - } - }); - - // ---- Registry helper method tests ---- - - it('libraries() returns only isLibrary adapters', () => { - const libs = registry.libraries(); - expect(libs.every((a) => a.isLibrary)).toBe(true); - expect(libs.length).toBe(5); - }); - - it('searchable() returns sorted by priority', () => { - const searchable = registry.searchable(); - const priorities = searchable.map((a) => a.searchPriority ?? Infinity); - for (let i = 1; i < priorities.length; i++) { - expect(priorities[i]).toBeGreaterThanOrEqual(priorities[i - 1]); - } - }); - - it('byMediaType filters correctly', () => { - const movieAdapters = registry.byMediaType('movie'); - expect(movieAdapters.every((a) => a.mediaTypes?.includes('movie'))).toBe(true); - }); - - it('resolveAuthAdapter follows authVia', () => { - const ss = registry.get('streamystats')!; - const authAdapter = registry.resolveAuthAdapter(ss); - expect(authAdapter?.id).toBe('jellyfin'); - }); -}); diff --git a/src/lib/adapters/base.ts b/src/lib/adapters/base.ts deleted file mode 100644 index 8f7b7803..00000000 --- a/src/lib/adapters/base.ts +++ /dev/null @@ -1,314 +0,0 @@ -/** - * Base adapter interface for Nexus service integrations. - * - * To create a custom adapter: - * 1. Create a new file in src/lib/adapters/ (e.g. my-service.ts) - * 2. Implement the ServiceAdapter interface - * 3. Register it in src/lib/adapters/registry.ts - * - * Only implement the methods your service supports — all are optional - * except `ping`, `id`, `displayName`, and `defaultPort`. - */ - -import type { ServiceConfig, ServiceHealth, NexusRequest, UnifiedMedia, UnifiedSearchResult, UserCredential, ExternalUser, NexusSession, SyncItem, CalendarItem } from './types'; -import type { NegotiatePlaybackFn } from './playback'; -import type { - AdapterCapabilities, - AdapterContractVersion, - AdapterTier, - CredentialProbeResult, - LinkedParentContext, - UserCredentialResult -} from './contract'; - -// Re-export contract types so downstream imports can grab them from base.ts -// without separately importing contract.ts. -export type { - AdapterCapabilities, - AdapterContractVersion, - AdapterTier, - CredentialProbeResult, - LinkedParentContext, - UserCredentialResult -}; - -export type OnboardingCategory = - | 'media-server' - | 'automation' - | 'requests' - | 'subtitles' - | 'analytics' - | 'video' - | 'games' - | 'books' - | 'indexer'; - -export interface OnboardingMeta { - category: OnboardingCategory; - description: string; - priority: number; - icon?: string; - requiredFields: ('url' | 'apiKey' | 'username' | 'password')[]; - supportsAutoAuth?: boolean; -} - -export interface ServiceAdapter { - /** Unique identifier matching the `type` field in the services table */ - readonly id: string; - - /** Human-readable name shown in the UI */ - readonly displayName: string; - - /** Default port for this service (used in setup wizard hints) */ - readonly defaultPort: number; - - /** Brand color for UI badges and accents (hex string, e.g. '#00a4dc') */ - readonly color?: string; - - /** 2-char abbreviation for compact badges (e.g. 'JF'). Falls back to first 2 chars of id. */ - readonly abbreviation?: string; - - /** Icon name or SVG string for the service */ - readonly icon?: string; - - /** Categories of media this adapter provides */ - readonly mediaTypes?: Array<'movie' | 'show' | 'book' | 'game' | 'music' | 'live' | 'video' | 'other'>; - - /** - * Whether individual users can/should link their own accounts. - * - true → user-level service (Jellyfin, Overseerr, Calibre, RomM) - * - false / undefined → server-level only (Radarr, Sonarr, etc.) - */ - readonly userLinkable?: boolean; - - // ---- Capability metadata ---- - - /** Whether this adapter provides a browsable media library */ - readonly isLibrary?: boolean; - - /** Whether this adapter should appear in unified search */ - readonly isSearchable?: boolean; - - /** Search result priority (0 = highest). Defaults to Infinity. */ - readonly searchPriority?: number; - - /** Delegates user auth to another adapter type (e.g. 'jellyfin') */ - readonly authVia?: string; - - /** No user-facing content — background enrichment only (e.g. Bazarr, Prowlarr) */ - readonly isEnrichmentOnly?: boolean; - - /** Parent adapter types this service can auto-link through. Order = preference. */ - readonly derivedFrom?: string[]; - - /** If true, this service ONLY works through a parent — no manual link fallback. */ - readonly parentRequired?: boolean; - - /** Poll interval in ms for pollSessions. Defaults to 10000 (10s). */ - readonly pollIntervalMs?: number; - - // ─── New contract fields (2026-04-14) ────────────────────────────────── - // These are optional during the migration; adapters populate them - // gradually. Once every adapter has them, they become required. See - // docs/superpowers/specs/2026-04-14-adapter-contract-design.md. - - /** Contract version this adapter was written against. */ - readonly contractVersion?: AdapterContractVersion; - - /** Adapter tier — describes how user credentials are obtained. */ - readonly tier?: AdapterTier; - - /** Formal capability declaration. Supersedes the loose per-field flags above. */ - readonly capabilities?: AdapterCapabilities; - - /** Check if the service is reachable */ - ping(config: ServiceConfig): Promise; - - // ─── New auth-resilience hooks (2026-04-14) ──────────────────────────── - // Optional during migration. Required for tier='user-standalone' and - // 'user-derived' once migration is complete. - - /** Cheap probe of the admin credential — required when capabilities.adminAuth.supportsHealthProbe. */ - probeAdminCredential?(config: ServiceConfig): Promise; - - /** Cheap probe of a user credential — required when capabilities.userAuth.supportsHealthProbe. */ - probeCredential?(config: ServiceConfig, userCred: UserCredential): Promise; - - /** Refresh an expired user credential using a stored password — required when capabilities.userAuth.supportsPasswordStorage. */ - refreshCredential?(config: ServiceConfig, userCred: UserCredential, storedPassword: string): Promise; - - /** Derived-tier only — given a parent credential, find the corresponding user credential on this adapter. */ - findAutoLinkMatch?(config: ServiceConfig, parent: LinkedParentContext): Promise; - - /** Items the user is currently in progress on */ - getContinueWatching?(config: ServiceConfig, userCred?: UserCredential): Promise; - - /** - * The "next" item after the given one — next episode of a show, next - * movie in a collection, etc. Used by the player's post-play up-next - * card (#19). Return `null` when there's nothing to play next. - * - * Adapters without a native concept of "next" should not implement this; - * the player gates its up-next UI on absence. - */ - getNextItem?(config: ServiceConfig, sourceId: string, userCred?: UserCredential): Promise; - - /** - * Skip markers (intro/credits/recap ranges) for the given item. - * Used by the player's floating Skip button (#19). Return empty array - * when none are known. - */ - getSkipMarkers?(config: ServiceConfig, sourceId: string, userCred?: UserCredential): Promise; - - /** Recently added items */ - getRecentlyAdded?(config: ServiceConfig, userCred?: UserCredential): Promise; - - /** Full-text search */ - search?(config: ServiceConfig, query: string, userCred?: UserCredential): Promise; - - /** Fetch a single item by its source ID */ - getItem?(config: ServiceConfig, sourceId: string, userCred?: UserCredential): Promise; - - /** Items currently being downloaded / in queue */ - getQueue?(config: ServiceConfig): Promise; - - /** Trending / recommended items */ - getTrending?(config: ServiceConfig, userCred?: UserCredential): Promise; - - /** Browse library items — paginated, optionally filtered by media type */ - getLibrary?( - config: ServiceConfig, - opts?: { type?: string; limit?: number; offset?: number; sortBy?: string; platformId?: number }, - userCred?: UserCredential - ): Promise<{ items: UnifiedMedia[]; total: number }>; - - /** Live TV channels */ - getLiveChannels?(config: ServiceConfig, userCred?: UserCredential): Promise; - - /** Submit a request for new media. For TV, pass `seasons` as array of season numbers. */ - requestMedia?(config: ServiceConfig, tmdbId: string, type: 'movie' | 'tv', userCred?: UserCredential, seasons?: number[]): Promise; - - /** Browse/discover content — paginated, for infinite scroll */ - discover?( - config: ServiceConfig, - opts?: { page?: number; category?: string; genreId?: string; networkId?: string }, - userCred?: UserCredential - ): Promise<{ items: UnifiedMedia[]; hasMore: boolean }>; - - /** - * Label for the "username" field on the account-linking form. - * Defaults to "Username" when absent. Set to "Email" for services that use email login. - * Set to "Jellyfin Username" when the service is configured for Jellyfin auth. - */ - readonly authUsernameLabel?: string; - - readonly onboarding?: OnboardingMeta; - - // ---- User-level methods (only relevant when userLinkable = true) ---- - - /** Authenticate a user against this service; returns an access token + userId */ - authenticateUser?(config: ServiceConfig, username: string, password: string, mode?: 'signin' | 'register'): Promise<{ accessToken: string; externalUserId: string; externalUsername: string; extraAuth?: Record }>; - - /** Create a new user on this service; returns auth info for the created user */ - createUser?(config: ServiceConfig, username: string, password: string): Promise<{ accessToken: string; externalUserId: string; externalUsername: string }>; - - /** List all users on this service (for migration) */ - getUsers?(config: ServiceConfig): Promise; - - /** Reset a user's password on this service (admin API) */ - resetPassword?(config: ServiceConfig, externalUserId: string, newPassword: string): Promise; - - /** Fetch similar items for a given item ID */ - getSimilar?(config: ServiceConfig, sourceId: string, userCred?: UserCredential): Promise; - - /** Fetch all episodes for a given season of a show */ - getSeasonEpisodes?(config: ServiceConfig, seriesId: string, seasonNumber: number, userCred?: UserCredential): Promise; - - /** Upcoming media releases within a date range */ - getCalendar?(config: ServiceConfig, start: string, end: string, - userCred?: UserCredential): Promise; - - // ---- Request management (Overseerr and similar) ---- - - /** - * List media requests. Admins (no userCred / admin API key) see all requests; - * users with a session cookie see only their own. - */ - getRequests?( - config: ServiceConfig, - opts?: { filter?: 'all' | 'pending' | 'approved' | 'declined' | 'available'; take?: number; skip?: number }, - userCred?: UserCredential - ): Promise; - - /** Fast count of pending requests (no enrichment) — for badge display */ - getPendingCount?(config: ServiceConfig): Promise; - - /** Approve a request by its sourceId — requires admin credentials */ - approveRequest?(config: ServiceConfig, requestId: string): Promise; - - /** Decline a request by its sourceId — requires admin credentials */ - denyRequest?(config: ServiceConfig, requestId: string): Promise; - - // ---- Extended methods (adapter consolidation) ---- - - /** Poll active playback/activity sessions */ - pollSessions?(config: ServiceConfig, userCred?: UserCredential): Promise; - - /** Sync all library items for recommendation engine */ - syncLibraryItems?(config: ServiceConfig, userCred?: UserCredential): Promise; - - /** Auth headers needed to proxy images from this service */ - getImageHeaders?(config: ServiceConfig, userCred?: UserCredential): Promise>; - - /** Sub-items: seasons, albums, tracks, platforms, collections */ - getSubItems?(config: ServiceConfig, parentId: string, type: string, - opts?: { limit?: number; offset?: number; sort?: string }, - userCred?: UserCredential): Promise<{ items: UnifiedMedia[]; total: number }>; - - /** Detail for child items: album tracks, season episodes */ - getSubItemDetail?(config: ServiceConfig, parentId: string, childId: string, - userCred?: UserCredential): Promise; - - /** Related items: same-author books, instant mix, similar games */ - getRelated?(config: ServiceConfig, sourceId: string, - userCred?: UserCredential): Promise; - - /** Browsing categories: genres, tags, platforms, authors */ - getCategories?(config: ServiceConfig, - userCred?: UserCredential): Promise>; - - /** Set item status: read/unread, favorite, watched */ - setItemStatus?(config: ServiceConfig, sourceId: string, - status: Record, userCred?: UserCredential): Promise; - - /** Collection/playlist CRUD */ - manageCollection?(config: ServiceConfig, - action: 'create' | 'update' | 'delete' | 'addItems' | 'removeItems', - data: { id?: string; name?: string; itemIds?: string[]; [key: string]: unknown }, - userCred?: UserCredential): Promise<{ id: string } | void>; - - /** Channel/creator subscriptions */ - manageSubscription?(config: ServiceConfig, - action: 'subscribe' | 'unsubscribe', - channelId: string, userCred?: UserCredential): Promise; - - /** Upload binary content (save states, save files) */ - uploadContent?(config: ServiceConfig, parentId: string, type: string, - blob: Blob, fileName: string, userCred?: UserCredential): Promise; - - /** Download binary content (books, ROMs, save states) */ - downloadContent?(config: ServiceConfig, sourceId: string, - format?: string, userCred?: UserCredential): Promise; - - /** Enrich an existing item with additional metadata */ - enrichItem?(config: ServiceConfig, item: UnifiedMedia, - enrichmentType?: string, userCred?: UserCredential): Promise; - - /** Fetch service-specific data that doesn't map to UnifiedMedia */ - getServiceData?(config: ServiceConfig, dataType: string, - params?: Record, - userCred?: UserCredential): Promise; - - /** Negotiate a playback session for a media item */ - negotiatePlayback?: NegotiatePlaybackFn; -} diff --git a/src/lib/adapters/bazarr.ts b/src/lib/adapters/bazarr.ts deleted file mode 100644 index 72e00676..00000000 --- a/src/lib/adapters/bazarr.ts +++ /dev/null @@ -1,599 +0,0 @@ -import type { ServiceAdapter } from './base'; -import type { ServiceConfig, ServiceHealth } from './types'; -import { withCache } from '../server/cache'; - -const capabilityBackoffUntil = new Map(); - -class BazarrCapabilityError extends Error { - constructor( - message: string, - readonly capabilityKey: string - ) { - super(message); - this.name = 'BazarrCapabilityError'; - } -} - -function getCapabilityCacheKey(config: ServiceConfig, capability: string) { - return `${config.id}:${capability}`; -} - -function isCapabilityBackedOff(config: ServiceConfig, capability: string) { - const key = getCapabilityCacheKey(config, capability); - const until = capabilityBackoffUntil.get(key); - if (!until) return false; - if (until <= Date.now()) { - capabilityBackoffUntil.delete(key); - return false; - } - return true; -} - -function backOffCapability(config: ServiceConfig, capability: string, ms = 10 * 60 * 1000) { - capabilityBackoffUntil.set(getCapabilityCacheKey(config, capability), Date.now() + ms); -} - -function isBazarrCapabilityError(error: unknown): error is BazarrCapabilityError { - return error instanceof BazarrCapabilityError; -} - -// --------------------------------------------------------------------------- -// Bazarr adapter -// -// Bazarr is a subtitle management companion for Sonarr and Radarr. It -// automatically downloads and manages subtitles for your media library. -// -// Config convention: -// url -> Bazarr instance URL (e.g. http://localhost:6767) -// apiKey -> Bazarr API key (Settings > General > Security > API Key) -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface SubtitleTrack { - language: string; - languageName: string; - hearingImpaired: boolean; - forced: boolean; - provider?: string; - score?: number; - filePath?: string; -} - -export interface SubtitleStatus { - tmdbId?: string; - radarrId?: string; - sonarrId?: string; - seriesId?: string; - seasonNumber?: number; - episodeNumber?: number; - title: string; - available: SubtitleTrack[]; - missing: string[]; - wanted: string[]; -} - -export interface SubtitleEvent { - timestamp: string; - mediaTitle: string; - episodeInfo?: string; - language: string; - provider: string; - action: 'downloaded' | 'upgraded' | 'failed' | 'deleted' | 'manual'; - score?: number; -} - -export interface SubtitleProvider { - name: string; - status: 'active' | 'throttled' | 'error' | 'disabled'; - error?: string; -} - -export interface LanguageProfile { - id: number; - name: string; - languages: Array<{ code: string; name: string; forced: boolean; hi: boolean }>; -} - -// --------------------------------------------------------------------------- -// Internal fetch helper -// --------------------------------------------------------------------------- - -async function bazarrFetch( - config: ServiceConfig, - path: string, - opts?: { method?: string; body?: string | FormData; timeoutMs?: number; rawResponse?: boolean } -): Promise { - const capability = - path.startsWith('/api/history/movies') ? 'history-movies' - : path.startsWith('/api/history/series') ? 'history-series' - : null; - - if (capability && isCapabilityBackedOff(config, capability)) { - throw new BazarrCapabilityError(`Bazarr ${path} temporarily disabled after compatibility failure`, capability); - } - - const timeoutMs = opts?.timeoutMs ?? 8000; - const url = `${config.url.replace(/\/+$/, '')}${path}`; - const headers: Record = { - 'X-API-KEY': config.apiKey ?? '', - Accept: 'application/json' - }; - // Only set Content-Type for string bodies (FormData sets its own boundary) - if (typeof opts?.body === 'string') { - headers['Content-Type'] = 'application/json'; - } - const res = await fetch(url, { - method: opts?.method ?? 'GET', - headers, - body: opts?.body, - signal: AbortSignal.timeout(timeoutMs) - }); - if (!res.ok) throw new Error(`Bazarr ${path} -> ${res.status}`); - if (opts?.rawResponse) return res; - const text = await res.text(); - if (!text) return {}; - // Detect HTML responses (Bazarr SPA fallback for unknown routes) - if (text.trimStart().startsWith(' l.code2 ?? l.code3 ?? '' - ); - const available: SubtitleTrack[] = (movie.subtitles ?? []).map(normalizeTrack); - const availableCodes = new Set(available.map((t) => t.language)); - const missing = wanted.filter((code) => !availableCodes.has(code)); - - return { - tmdbId: movie.tmdbId != null ? String(movie.tmdbId) : undefined, - radarrId: movie.radarrId != null ? String(movie.radarrId) : undefined, - title: movie.title ?? '', - available, - missing, - wanted - }; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function normalizeEpisodeStatus(ep: any): SubtitleStatus { - const wanted: string[] = (ep.languages ?? []).map( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (l: any) => l.code2 ?? l.code3 ?? '' - ); - const available: SubtitleTrack[] = (ep.subtitles ?? []).map(normalizeTrack); - const availableCodes = new Set(available.map((t) => t.language)); - const missing = wanted.filter((code) => !availableCodes.has(code)); - - return { - sonarrId: ep.sonarrSeriesId != null ? String(ep.sonarrSeriesId) : undefined, - seriesId: ep.seriesId != null ? String(ep.seriesId) : undefined, - seasonNumber: ep.season ?? undefined, - episodeNumber: ep.episode ?? undefined, - title: ep.title ?? '', - available, - missing, - wanted - }; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function normalizeHistoryEvent(entry: any): SubtitleEvent { - const actionStr = String(entry.action ?? '').toLowerCase(); - let action: SubtitleEvent['action'] = 'downloaded'; - if (actionStr.includes('upgrade')) action = 'upgraded'; - else if (actionStr.includes('fail')) action = 'failed'; - else if (actionStr.includes('delet')) action = 'deleted'; - else if (actionStr.includes('manual')) action = 'manual'; - - let episodeInfo: string | undefined; - if (entry.episode_number != null && entry.season != null) { - const s = String(entry.season).padStart(2, '0'); - const e = String(entry.episode_number).padStart(2, '0'); - episodeInfo = `S${s}E${e}`; - } - - const language = entry.language?.code2 ?? entry.language?.name ?? ''; - - return { - timestamp: entry.timestamp ?? '', - mediaTitle: entry.seriesTitle ?? entry.title ?? '', - episodeInfo, - language, - provider: entry.provider ?? '', - action, - score: entry.score ?? undefined - }; -} - -// --------------------------------------------------------------------------- -// Exported enrichment helpers -// --------------------------------------------------------------------------- - -export async function getSubtitleStatus( - config: ServiceConfig, - tmdbId?: string, - opts?: { radarrId?: string; sonarrId?: string; type?: string } -): Promise { - const cacheKey = `bazarr:status:${config.id}:${tmdbId ?? ''}:${opts?.radarrId ?? ''}:${opts?.sonarrId ?? ''}:${opts?.type ?? ''}`; - - return withCache(cacheKey, 120_000, async () => { - const isShow = opts?.type === 'show' || opts?.type === 'episode' || !!opts?.sonarrId; - - // Try TMDB ID first - if (tmdbId) { - try { - if (isShow) { - const data = (await bazarrFetch(config, `/api/series?tmdbid[]=${tmdbId}`)) as { data?: unknown[] }; - const items = data.data ?? (Array.isArray(data) ? data : []); - if (items.length > 0) return normalizeEpisodeStatus(items[0]); - } else { - const data = (await bazarrFetch(config, `/api/movies?tmdbid[]=${tmdbId}`)) as { data?: unknown[] }; - const items = data.data ?? (Array.isArray(data) ? data : []); - if (items.length > 0) return normalizeMovieStatus(items[0]); - } - } catch { /* fall through to ID lookup */ } - } - - // Fall back to Radarr/Sonarr ID - if (opts?.radarrId) { - try { - const movie = await bazarrFetch(config, `/api/movies/${opts.radarrId}`); - return normalizeMovieStatus(movie); - } catch { /* no match */ } - } - if (opts?.sonarrId) { - try { - const series = await bazarrFetch(config, `/api/series/${opts.sonarrId}`); - return normalizeEpisodeStatus(series); - } catch { /* no match */ } - } - - return null; - }); -} - -export async function getSeasonSubtitleStatus( - config: ServiceConfig, - sonarrSeriesId: number, - seasonNumber: number -): Promise { - const cacheKey = `bazarr:season:${config.id}:${sonarrSeriesId}:${seasonNumber}`; - - return withCache(cacheKey, 120_000, async () => { - try { - const data = (await bazarrFetch( - config, - `/api/episodes?seriesid[]=${sonarrSeriesId}` - )) as { data?: unknown[] }; - const episodes = data.data ?? (Array.isArray(data) ? data : []); - return episodes - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .filter((ep: any) => ep.season === seasonNumber) - .map(normalizeEpisodeStatus); - } catch (e) { - console.error(`[Bazarr] getSeasonSubtitleStatus failed:`, e); - return []; - } - }); -} - -export async function getItemSubtitleHistory( - config: ServiceConfig, - tmdbId?: string, - opts?: { radarrId?: string; sonarrId?: string; type?: string } -): Promise { - const cacheKey = `bazarr:history:${config.id}:${tmdbId ?? ''}:${opts?.radarrId ?? ''}:${opts?.sonarrId ?? ''}:${opts?.type ?? ''}`; - - return withCache(cacheKey, 120_000, async () => { - try { - const isShow = opts?.type === 'show' || opts?.type === 'episode' || !!opts?.sonarrId; - const endpoint = isShow ? '/api/history/series' : '/api/history/movies'; - - const data = (await bazarrFetch(config, endpoint)) as { data?: unknown[] }; - const events = data.data ?? (Array.isArray(data) ? data : []); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const filtered = events.filter((entry: any) => { - if (tmdbId && String(entry.tmdbId) === tmdbId) return true; - if (opts?.radarrId && String(entry.radarrId) === opts.radarrId) return true; - if (opts?.sonarrId && String(entry.sonarrSeriesId) === opts.sonarrId) return true; - return false; - }); - - return filtered.map(normalizeHistoryEvent); - } catch (e) { - if (isBazarrCapabilityError(e)) { - return []; - } - console.error(`[Bazarr] getItemSubtitleHistory failed:`, e); - return []; - } - }); -} - -// --------------------------------------------------------------------------- -// Exported admin-level functions -// --------------------------------------------------------------------------- - -export async function getProviderStatus(config: ServiceConfig): Promise { - return withCache(`bazarr:providers:${config.id}`, 30_000, async () => { - try { - const raw = await bazarrFetch(config, '/api/providers'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const items: any[] = Array.isArray(raw) ? raw : (raw as any).data ?? []; - - return items.map((p) => { - const rawStatus = String(p.status ?? '').toLowerCase(); - let status: SubtitleProvider['status'] = 'active'; - if (rawStatus.includes('throttl')) status = 'throttled'; - else if (rawStatus.includes('disabled')) status = 'disabled'; - else if (rawStatus.includes('error') || rawStatus.includes('fail')) status = 'error'; - - return { - name: p.name ?? '', - status, - error: p.error ?? undefined - }; - }); - } catch (e) { - console.error(`[Bazarr] getProviderStatus failed:`, e); - return []; - } - }); -} - -export async function getLanguageProfiles(config: ServiceConfig): Promise { - return withCache(`bazarr:profiles:${config.id}`, 300_000, async () => { - try { - const raw = await bazarrFetch(config, '/api/languages/profiles'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const items: any[] = Array.isArray(raw) ? raw : (raw as any).data ?? []; - - return items.map((p) => ({ - id: p.profileId ?? p.id ?? 0, - name: p.name ?? '', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - languages: (p.items ?? p.languages ?? []).map((l: any) => ({ - code: l.code2 ?? l.code3 ?? l.language ?? '', - name: l.name ?? l.long_name ?? '', - forced: !!l.forced, - hi: !!l.hi - })) - })); - } catch (e) { - console.error(`[Bazarr] getLanguageProfiles failed:`, e); - return []; - } - }); -} - -export async function getSystemHistory( - config: ServiceConfig, - opts?: { page?: number; limit?: number } -): Promise<{ events: SubtitleEvent[]; total: number }> { - const page = opts?.page ?? 1; - const limit = opts?.limit ?? 25; - const cacheKey = `bazarr:syshistory:${config.id}:p${page}:l${limit}`; - - return withCache<{ events: SubtitleEvent[]; total: number }>(cacheKey, 30_000, async () => { - try { - const start = (page - 1) * limit; - const [movieRaw, seriesRaw] = await Promise.all([ - bazarrFetch(config, `/api/history/movies?start=${start}&length=${limit}`), - bazarrFetch(config, `/api/history/series?start=${start}&length=${limit}`) - ]); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const movieData = movieRaw as any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const seriesData = seriesRaw as any; - - const movieEvents: unknown[] = movieData.data ?? (Array.isArray(movieData) ? movieData : []); - const seriesEvents: unknown[] = seriesData.data ?? (Array.isArray(seriesData) ? seriesData : []); - - const allEvents = [...movieEvents, ...seriesEvents] - .map(normalizeHistoryEvent) - .sort((a, b) => (b.timestamp > a.timestamp ? 1 : b.timestamp < a.timestamp ? -1 : 0)) - .slice(0, limit); - - const total = - (movieData.recordsTotal ?? movieEvents.length) + - (seriesData.recordsTotal ?? seriesEvents.length); - - return { events: allEvents, total }; - } catch (e) { - if (isBazarrCapabilityError(e)) { - return { events: [], total: 0 }; - } - console.error(`[Bazarr] getSystemHistory failed:`, e); - return { events: [], total: 0 }; - } - }); -} - -// --------------------------------------------------------------------------- -// Exported action helpers -// --------------------------------------------------------------------------- - -export async function resetProviders(config: ServiceConfig): Promise { - await bazarrFetch(config, '/api/providers', { - method: 'POST', - body: JSON.stringify({ action: 'reset' }) - }); -} - -// --------------------------------------------------------------------------- -// Adapter -// --------------------------------------------------------------------------- - -export const bazarrAdapter: ServiceAdapter = { - id: 'bazarr', - displayName: 'Bazarr', - defaultPort: 6767, - color: '#e0b818', - abbreviation: 'BZ', - isEnrichmentOnly: true, - - contractVersion: 1, - tier: 'server', - capabilities: { - enrichmentOnly: true, - adminAuth: { - required: true, - fields: ['url', 'adminApiKey'], - supportsHealthProbe: true - } - }, - - async probeAdminCredential(config) { - try { - const res = await fetch(`${config.url}/api/system/status?apikey=${encodeURIComponent(config.apiKey ?? '')}`, { - signal: AbortSignal.timeout(5000) - }); - if (res.status === 401 || res.status === 403) return 'invalid'; - if (!res.ok) return 'expired'; - return 'ok'; - } catch { - return 'expired'; - } - }, - - icon: 'bazarr', - onboarding: { - category: 'subtitles', - description: 'Manage subtitles across your library', - priority: 1, - requiredFields: ['url', 'apiKey'], - }, - - async ping(config: ServiceConfig): Promise { - const start = Date.now(); - try { - await bazarrFetch(config, '/api/system/health', { timeoutMs: 5000 }); - return { - serviceId: config.id, - name: config.name, - type: 'bazarr', - online: true, - latency: Date.now() - start - }; - } catch (e) { - return { - serviceId: config.id, - name: config.name, - type: 'bazarr', - online: false, - latency: Date.now() - start, - error: e instanceof Error ? e.message : String(e) - }; - } - }, - - async setItemStatus(config: ServiceConfig, sourceId: string, status: Record): Promise { - const action = status.action as string; - - if (action === 'download-subtitle') { - const isMovie = status.mediaType === 'movie'; - const endpoint = isMovie ? '/api/movies/subtitles' : '/api/episodes/subtitles'; - await bazarrFetch(config, endpoint, { - method: 'PATCH', - body: JSON.stringify({ - id: Number(sourceId), - language: status.language, - hi: status.hi ?? false, - forced: status.forced ?? false, - ...(status.provider ? { provider: status.provider } : {}) - }) - }); - } - - if (action === 'sync-subtitle') { - await bazarrFetch(config, '/api/subtitles', { - method: 'PATCH', - body: JSON.stringify({ - action: 'sync', - language: status.language, - path: status.path, - id: Number(sourceId), - mediaType: status.mediaType === 'movie' ? 'radarr' : 'sonarr' - }) - }); - } - - if (action === 'translate-subtitle') { - await bazarrFetch(config, '/api/subtitles', { - method: 'PATCH', - body: JSON.stringify({ - action: 'translate', - language: status.language, - path: status.path, - id: Number(sourceId), - mediaType: status.mediaType === 'movie' ? 'radarr' : 'sonarr' - }) - }); - } - - if (action === 'delete-subtitle') { - const isMovie = status.mediaType === 'movie'; - const endpoint = isMovie ? '/api/movies/subtitles' : '/api/episodes/subtitles'; - await bazarrFetch(config, endpoint, { - method: 'DELETE', - body: JSON.stringify({ - id: Number(sourceId), - language: status.language, - path: status.path - }) - }); - } - }, - - async uploadContent(config: ServiceConfig, parentId: string, type: string, blob: Blob, fileName: string): Promise { - if (type !== 'subtitle') return; - - // Determine media type from fileName convention: "movie:en" or "episode:en" - // The parentId encodes the Radarr/Sonarr ID, and the caller should pass - // mediaType info via the fileName as "movie/{radarrId}/{lang}" or "episode/{sonarrEpisodeId}/{lang}" - const parts = fileName.split('/'); - const isMovie = parts[0] === 'movie'; - const endpoint = isMovie ? '/api/movies/subtitles' : '/api/episodes/subtitles'; - const language = parts[2] ?? 'en'; - - const form = new FormData(); - form.append('file', blob, fileName); - form.append('id', parentId); - form.append('language', language); - form.append('hi', 'false'); - form.append('forced', 'false'); - - await bazarrFetch(config, endpoint, { - method: 'POST', - body: form - }); - } -}; diff --git a/src/lib/adapters/calibre.ts b/src/lib/adapters/calibre.ts deleted file mode 100644 index 4899c013..00000000 --- a/src/lib/adapters/calibre.ts +++ /dev/null @@ -1,575 +0,0 @@ -import type { ServiceAdapter } from './base'; -import type { ServiceConfig, ServiceHealth, UnifiedMedia, UnifiedSearchResult, UserCredential } from './types'; -import { withCache } from '../server/cache'; -import { AdapterAuthError } from './errors'; -import { opdsFetch, opdsFetchAllPages, opdsPing } from './calibre/opds-client'; -import { opdsEntryToUnifiedMedia, acquisitionsToFormats } from './calibre/normalize'; -import { sessionPost, sessionGet, getSessionCookie } from './calibre/session-client'; -import type { OpdsEntry, CalibreFormat } from './calibre/types'; - -// --------------------------------------------------------------------------- -// Calibre-Web adapter — OPDS-first rewrite (2026-04-13). -// See docs/superpowers/specs/2026-04-13-calibre-adapter-rewrite-design.md -// Read path is 100% OPDS (HTTP Basic auth). Session-cookie flow is used for -// setItemStatus (toggleread) and createUser only. -// --------------------------------------------------------------------------- - -const ALL_BOOKS_CACHE_MS = 300_000; - -async function fetchAllBooks(config: ServiceConfig, userCred?: UserCredential): Promise { - return withCache(`calibre-opds-all:${config.id}:${userCred?.externalUsername ?? ''}`, ALL_BOOKS_CACHE_MS, () => - opdsFetchAllPages(config, '/opds/books/letter/00', userCred) - ); -} - -function sortEntriesInMemory(entries: OpdsEntry[], sortBy: string | undefined): OpdsEntry[] { - if (!sortBy || sortBy === 'title') { - return [...entries].sort((a, b) => a.title.localeCompare(b.title)); - } - if (sortBy === 'year') { - return [...entries].sort((a, b) => { - const ay = a.published?.getTime() ?? 0; - const by = b.published?.getTime() ?? 0; - return by - ay; - }); - } - if (sortBy === 'rating') { - return [...entries].sort((a, b) => (b.ratingStars ?? 0) - (a.ratingStars ?? 0)); - } - if (sortBy === 'added') { - return [...entries].sort((a, b) => { - const au = a.updated?.getTime() ?? 0; - const bu = b.updated?.getTime() ?? 0; - return bu - au; - }); - } - return entries; -} - -export const calibreAdapter: ServiceAdapter = { - id: 'calibre', - displayName: 'Calibre-Web', - defaultPort: 8083, - color: '#7b68ee', - abbreviation: 'CA', - isLibrary: true, - isSearchable: true, - searchPriority: 0, - icon: 'calibre', - mediaTypes: ['book'], - userLinkable: true, - onboarding: { - category: 'books', - description: 'Read books, take notes, and track reading progress', - priority: 1, - requiredFields: ['url', 'username', 'password'], - supportsAutoAuth: true - }, - - contractVersion: 1, - tier: 'user-standalone', - capabilities: { - media: ['book'], - // Calibre-Web's admin account is just a regular user credential used by - // Nexus for reads when no per-user cred is available. Optional. - adminAuth: { - required: false, - fields: ['url', 'adminUsername', 'adminPassword'], - supportsHealthProbe: true - }, - userAuth: { - userLinkable: true, - usernameLabel: 'Username', - supportsRegistration: false, - supportsAccountCreation: true, - supportsPasswordStorage: true, - supportsHealthProbe: true - }, - library: true, - search: { priority: 0 } - }, - - async probeAdminCredential(config) { - try { - // Anonymous GET /opds returns 401 when Basic is needed — always is for Calibre-Web - const res = await fetch(`${config.url}/opds`, { - headers: { - Authorization: `Basic ${Buffer.from(`${config.username ?? ''}:${config.password ?? ''}`, 'utf-8').toString('base64')}` - }, - signal: AbortSignal.timeout(5000) - }); - if (res.status === 401) return 'invalid'; - if (!res.ok) return 'expired'; - return 'ok'; - } catch { - return 'expired'; - } - }, - - async probeCredential(config, userCred) { - try { - const user = userCred.externalUsername ?? ''; - const pass = userCred.accessToken ?? ''; - if (!user || !pass) return 'invalid'; - const res = await fetch(`${config.url}/opds`, { - headers: { - Authorization: `Basic ${Buffer.from(`${user}:${pass}`, 'utf-8').toString('base64')}` - }, - signal: AbortSignal.timeout(5000) - }); - if (res.status === 401) return 'invalid'; - if (!res.ok) return 'expired'; - return 'ok'; - } catch { - return 'expired'; - } - }, - - async refreshCredential(config, userCred, storedPassword) { - // Calibre-Web Basic auth — the "refresh" is just re-authenticating with - // the same (username, stored password) pair against /opds. - const username = userCred.externalUsername; - if (!username) throw new Error('Calibre-Web refresh: missing username'); - const testConfig: ServiceConfig = { ...config, username, password: storedPassword }; - await opdsPing(testConfig); - return { - accessToken: storedPassword, - externalUserId: username, - externalUsername: username - }; - }, - - async ping(config): Promise { - const start = Date.now(); - try { - await opdsPing(config); - return { - serviceId: config.id, - name: config.name, - type: 'calibre', - online: true, - latency: Date.now() - start - }; - } catch (e) { - return { - serviceId: config.id, - name: config.name, - type: 'calibre', - online: false, - error: String(e) - }; - } - }, - - async getContinueWatching(): Promise { - // Calibre has no per-page reading progress upstream. The previous - // implementation read /opds/unreadbooks and stamped a fake - // progress=0.05 on every entry, which polluted the unified - // Continue Watching row. Deleted as part of the 2026-04-17 player - // alignment plan (#12). If issue #11 (real page tracking) ever - // lands, Calibre progress will flow through `play_sessions` like - // everything else — not through this method. - return []; - }, - - async getRecentlyAdded(config, userCred): Promise { - try { - const feed = await opdsFetch(config, '/opds/new', userCred); - return feed.entries.slice(0, 20).map((entry) => opdsEntryToUnifiedMedia(config, entry)); - } catch { - return []; - } - }, - - async search(config, query, userCred): Promise { - try { - const encoded = encodeURIComponent(query); - const feed = await opdsFetch(config, `/opds/search/${encoded}`, userCred); - const items = feed.entries.map((entry) => opdsEntryToUnifiedMedia(config, entry)); - return { items, total: items.length, source: 'calibre' }; - } catch { - return { items: [], total: 0, source: 'calibre' }; - } - }, - - async getItem(config, sourceId, userCred): Promise { - try { - // OPDS search doesn't support field syntax (id:N / title:X return empty), so - // we walk the cached all-books feed. With the 5-min cache this is cheap. - const all = await fetchAllBooks(config, userCred); - const match = all.find((e) => e.id === sourceId); - return match ? opdsEntryToUnifiedMedia(config, match) : null; - } catch { - return null; - } - }, - - async getLibrary(config, opts, userCred): Promise<{ items: UnifiedMedia[]; total: number }> { - try { - const sortBy = opts?.sortBy; - const offset = opts?.offset ?? 0; - const limit = opts?.limit ?? 50; - - // For `added` and `rating`, prefer the dedicated OPDS feeds when the caller - // just wants the first page; these return server-sorted results and don't - // require a full library walk. - if (sortBy === 'added' && offset === 0) { - const feed = await opdsFetch(config, '/opds/new', userCred); - const items = feed.entries.slice(0, limit).map((e) => opdsEntryToUnifiedMedia(config, e)); - return { items, total: feed.totalResults ?? items.length }; - } - if (sortBy === 'rating' && offset === 0) { - const feed = await opdsFetch(config, '/opds/rated', userCred); - const items = feed.entries.slice(0, limit).map((e) => opdsEntryToUnifiedMedia(config, e)); - return { items, total: feed.totalResults ?? items.length }; - } - - // All other cases: full walk + in-memory sort + slice. - const all = await fetchAllBooks(config, userCred); - const sorted = sortEntriesInMemory(all, sortBy); - const page = sorted.slice(offset, offset + limit); - return { - items: page.map((e) => opdsEntryToUnifiedMedia(config, e)), - total: all.length - }; - } catch { - return { items: [], total: 0 }; - } - }, - - async authenticateUser(config, username, password) { - // OPDS root with Basic auth is the cheapest way to verify credentials. - const testConfig: ServiceConfig = { ...config, username, password }; - try { - await opdsPing(testConfig); - } catch (err) { - // opdsPing throws plain Error with messages like "authentication failed" - // or "unreachable". Map to structured AdapterAuthError so the shared UI - // copy renders the right message. - const msg = err instanceof Error ? err.message : String(err); - if (/401|authentic|password/i.test(msg)) { - throw new AdapterAuthError('Invalid Calibre-Web credentials', 'invalid'); - } - if (/unreach|ENOTFOUND|ECONNREFUSED|timeout|abort/i.test(msg)) { - throw new AdapterAuthError(`Cannot reach Calibre-Web at ${config.url}`, 'unreachable'); - } - throw new AdapterAuthError(msg, 'invalid'); - } - return { - accessToken: password, - externalUserId: username, - externalUsername: username - }; - }, - - async createUser(config, username, password) { - // Admin creates new users via the /admin/user/new form — session cookie required. - const formRes = await sessionGet(config, '/admin/user/new'); - if (!formRes.ok) { - throw new Error(`Calibre-Web /admin/user/new → HTTP ${formRes.status}`); - } - const html = await formRes.text(); - const csrfMatch = html.match(/name="csrf_token"\s+value="([^"]+)"/); - if (!csrfMatch) { - throw new Error('Calibre-Web: could not find csrf_token on /admin/user/new — is the admin account configured correctly?'); - } - - const defaultLang = - html.match(/name="default_language"[^>]*>[\s\S]*?]*selected[^>]*value="([^"]*)"/)?.[1] ?? - html.match(/id="default_language"[^>]*>[\s\S]*?]*selected[^>]*value="([^"]*)"/)?.[1] ?? - 'all'; - const locale = - html.match(/name="locale"[^>]*>[\s\S]*?]*selected[^>]*value="([^"]*)"/)?.[1] ?? - html.match(/id="locale"[^>]*>[\s\S]*?]*selected[^>]*value="([^"]*)"/)?.[1] ?? - 'en'; - - const body = new URLSearchParams({ - name: username, - email: `${username}@nexus.local`, - password, - default_language: defaultLang, - locale, - csrf_token: csrfMatch[1] - }); - - const createRes = await sessionPost(config, '/admin/user/new', { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: body.toString() - }); - - // Calibre-Web returns 200 for failures and 302 for success. Detect flash text. - if (createRes.status !== 302) { - const resHtml = await createRes.text(); - const lower = resHtml.toLowerCase(); - const alreadyExists = - lower.includes('existing account') || - lower.includes('already exists') || - lower.includes('is already taken'); - if (alreadyExists) { - // User already exists — try to authenticate with the provided password. - const testConfig: ServiceConfig = { ...config, username, password }; - try { - await opdsPing(testConfig); - return { accessToken: password, externalUserId: username, externalUsername: username }; - } catch { - throw new Error(`Calibre-Web user "${username}" already exists — link manually via My Accounts`); - } - } - if (lower.includes('please complete all fields')) { - throw new Error('Calibre-Web user creation failed: missing required fields'); - } - if (lower.includes('database error')) { - throw new Error('Calibre-Web user creation failed: database error'); - } - throw new Error('Calibre-Web user creation failed — check admin credentials and permissions'); - } - - // Verify by authenticating as the new user - const testConfig: ServiceConfig = { ...config, username, password }; - await opdsPing(testConfig); - return { - accessToken: password, - externalUserId: username, - externalUsername: username - }; - }, - - async getImageHeaders(config, userCred): Promise> { - try { - const user = userCred?.externalUsername ?? config.username ?? ''; - const pass = userCred?.accessToken ?? config.password ?? ''; - if (!user || !pass) return {}; - const token = Buffer.from(`${user}:${pass}`, 'utf-8').toString('base64'); - return { Authorization: `Basic ${token}` }; - } catch { - return {}; - } - }, - - async getServiceData(config, dataType, _params, userCred) { - switch (dataType) { - case 'series': - return getCalibreSeries(config, userCred); - case 'all': - return getAllBooks(config, userCred); - case 'categories': - return getCalibreCategories(config, userCred); - case 'authors': - return getCalibreAuthors(config, userCred); - default: - return null; - } - }, - - async enrichItem(config, item, enrichmentType, userCred) { - if (enrichmentType === 'formats') { - const formats = await getCalibreBookFormats(config, item.sourceId, userCred); - return { ...item, metadata: { ...item.metadata, formats: formats.formats } }; - } - if (enrichmentType === 'related') { - const related = await getRelatedBooks(config, item.sourceId, userCred); - return { ...item, metadata: { ...item.metadata, related } }; - } - return item; - }, - - async setItemStatus(config, sourceId, status, userCred) { - if ((status as Record).read != null) { - await toggleReadStatus(config, sourceId, userCred); - } - }, - - async downloadContent(config, sourceId, format, userCred) { - return downloadBook(config, sourceId, format ?? 'epub', userCred); - } -}; - -// --------------------------------------------------------------------------- -// Exported helpers (internal to this module; called via getServiceData / enrichItem) -// --------------------------------------------------------------------------- - -export interface CalibreSeries { - name: string; - books: UnifiedMedia[]; -} - -export async function getCalibreSeries(config: ServiceConfig, userCred?: UserCredential): Promise { - // IMPORTANT: do NOT swallow errors inside the withCache callback. Swallowing - // to `[]` would pollute the 5-minute cache with an empty result so a transient - // Calibre hiccup blacked out the library for 5 minutes after recovery. - // Let errors propagate — withCache never stores a rejected promise. Callers - // are expected to handle the throw at their level (usually via a ping-first - // status check; see routes/books/+page.server.ts). - return withCache(`calibre-series:${config.id}`, 300_000, async () => { - const entries = await fetchAllBooks(config, userCred); - const map = new Map(); - for (const entry of entries) { - if (!entry.series) continue; - const item = opdsEntryToUnifiedMedia(config, entry); - const existing = map.get(entry.series) ?? []; - existing.push(item); - map.set(entry.series, existing); - } - return Array.from(map.entries()).map(([name, books]) => ({ name, books })); - }); -} - -export interface CalibreAuthor { - name: string; - bookCount: number; -} - -export async function getCalibreAuthors(config: ServiceConfig, userCred?: UserCredential): Promise { - // See getCalibreSeries — errors must propagate to avoid cache poisoning. - return withCache(`calibre-authors:${config.id}`, 300_000, async () => { - const entries = await fetchAllBooks(config, userCred); - const map = new Map(); - for (const entry of entries) { - for (const author of entry.authors) { - const trimmed = author.trim(); - if (trimmed) map.set(trimmed, (map.get(trimmed) ?? 0) + 1); - } - } - return Array.from(map.entries()) - .map(([name, bookCount]) => ({ name, bookCount })) - .sort((a, b) => b.bookCount - a.bookCount); - }); -} - -export async function getCalibreCategories(config: ServiceConfig, userCred?: UserCredential): Promise { - // See getCalibreSeries — errors must propagate to avoid cache poisoning. - return withCache(`calibre-categories:${config.id}`, 300_000, async () => { - const entries = await fetchAllBooks(config, userCred); - const tags = new Set(); - for (const entry of entries) { - for (const cat of entry.categories) { - const trimmed = cat.trim(); - if (trimmed) tags.add(trimmed); - } - } - return Array.from(tags).sort(); - }); -} - -export interface CalibreBookFormats { - formats: CalibreFormat[]; -} - -export async function getCalibreBookFormats( - config: ServiceConfig, - bookId: string, - userCred?: UserCredential -): Promise { - try { - const all = await fetchAllBooks(config, userCred); - const match = all.find((e) => e.id === bookId); - if (!match) return { formats: [] }; - return { formats: acquisitionsToFormats(match) }; - } catch { - return { formats: [] }; - } -} - -export interface RelatedBooks { - sameAuthor: UnifiedMedia[]; - sameSeries: UnifiedMedia[]; - nextInSeries?: UnifiedMedia; - prevInSeries?: UnifiedMedia; -} - -export async function getRelatedBooks( - config: ServiceConfig, - bookId: string, - userCred?: UserCredential -): Promise { - try { - const entries = await fetchAllBooks(config, userCred); - const current = entries.find((e) => e.id === bookId); - if (!current) return { sameAuthor: [], sameSeries: [] }; - - const currentAuthors = new Set(current.authors.map((a) => a.trim())); - const sameAuthor: UnifiedMedia[] = []; - const sameSeries: UnifiedMedia[] = []; - let nextInSeries: UnifiedMedia | undefined; - let prevInSeries: UnifiedMedia | undefined; - - for (const entry of entries) { - if (entry.id === bookId) continue; - if (entry.authors.some((a) => currentAuthors.has(a.trim()))) { - sameAuthor.push(opdsEntryToUnifiedMedia(config, entry)); - } - if (current.series && entry.series === current.series) { - const item = opdsEntryToUnifiedMedia(config, entry); - sameSeries.push(item); - if ( - current.seriesIndex !== undefined && - entry.seriesIndex !== undefined && - entry.seriesIndex === current.seriesIndex + 1 - ) { - nextInSeries = item; - } - if ( - current.seriesIndex !== undefined && - entry.seriesIndex !== undefined && - entry.seriesIndex === current.seriesIndex - 1 - ) { - prevInSeries = item; - } - } - } - - return { sameAuthor: sameAuthor.slice(0, 20), sameSeries, nextInSeries, prevInSeries }; - } catch { - return { sameAuthor: [], sameSeries: [] }; - } -} - -export async function toggleReadStatus( - config: ServiceConfig, - bookId: string, - userCred?: UserCredential -): Promise { - const res = await sessionPost(config, `/ajax/toggleread/${bookId}`, {}, userCred); - return res.ok; -} - -export async function downloadBook( - config: ServiceConfig, - bookId: string, - format: string, - userCred?: UserCredential -): Promise { - // Use /opds/download/{id}/{fmt}/ with Basic auth — no session cookie needed. - const user = userCred?.externalUsername ?? config.username ?? ''; - const pass = userCred?.accessToken ?? config.password ?? ''; - if (!user || !pass) { - // Fall back to session-cookie download if no basic creds available. - const cookie = await getSessionCookie(config, userCred); - const res = await fetch(`${config.url}/download/${bookId}/${format.toLowerCase()}`, { - headers: { Cookie: cookie }, - signal: AbortSignal.timeout(30_000), - redirect: 'follow' - }); - if (!res.ok) throw new Error(`Calibre-Web download failed: ${res.status}`); - return res; - } - const token = Buffer.from(`${user}:${pass}`, 'utf-8').toString('base64'); - const res = await fetch(`${config.url}/opds/download/${bookId}/${format.toLowerCase()}/`, { - headers: { Authorization: `Basic ${token}` }, - signal: AbortSignal.timeout(30_000) - }); - if (!res.ok) throw new Error(`Calibre-Web download failed: ${res.status}`); - return res; -} - -/** Fetch all books as UnifiedMedia (cached) — used by enrichment data. */ -export async function getAllBooks( - config: ServiceConfig, - userCred?: UserCredential -): Promise { - // See getCalibreSeries — errors must propagate to avoid cache poisoning. - return withCache(`calibre-allbooks:${config.id}`, 300_000, async () => { - const entries = await fetchAllBooks(config, userCred); - return entries.map((e) => opdsEntryToUnifiedMedia(config, e)); - }); -} diff --git a/src/lib/adapters/calibre/__tests__/fixtures/opds-books-all.xml b/src/lib/adapters/calibre/__tests__/fixtures/opds-books-all.xml deleted file mode 100644 index a612e739..00000000 --- a/src/lib/adapters/calibre/__tests__/fixtures/opds-books-all.xml +++ /dev/null @@ -1,284 +0,0 @@ - - - /static/favicon.ico - urn:uuid:2853dacf-ed79-42f5-8e8a-a7bb3d1ae6a2 - 2026-04-13T21:07:22+00:00 - - - - - - - - - Calibre-Web - - Calibre-Web - https://github.com/janeczku/calibre-web - - - - - - Dune - urn:uuid:02a148e6-9d58-4000-9818-d6b8f71c7abf - 2026-04-14T03:38:04+00:00 - - - Frank Herbert - - - - - Chilton - - - 1965-08-01T00:00:00+00:00 - - eng - - - - - - -
- - RATING: ★★★★
- - - TAGS: Classic, SciFi
- - - SERIES: Dune [1]
- - - - - -

Desert planet of Arrakis.

- -
- - - - -
- - - The Fellowship of the Ring - urn:uuid:50f0e269-5d3f-4c40-8688-ae4a5827a962 - 2026-04-14T03:38:04+00:00 - - - J.R.R. Tolkien - - - - - Allen & Unwin - - - 1954-07-29T00:00:00+00:00 - - eng - - - - - - -
- - RATING: ★★★★★
- - - TAGS: Epic, Fantasy
- - - SERIES: Middle-earth [2]
- - - - - -

Frodo inherits the One Ring.

- -
- - - - -
- - - The Hobbit - urn:uuid:b57862dd-5e0d-4aa3-bf11-8ebbb74c179b - 2026-04-14T03:38:04+00:00 - - - J.R.R. Tolkien - - - - - Allen & Unwin - - - 1937-09-21T00:00:00+00:00 - - eng - - - - - - -
- - RATING: ★★★★★
- - - TAGS: Classic, Fantasy
- - - SERIES: Middle-earth [1]
- - - - - -

A hobbit's unexpected journey.

- -
- - - - -
- - - Neuromancer - urn:uuid:9d8f7157-c055-4afc-a737-969c0105e7b7 - 2026-04-14T03:38:04+00:00 - - - William Gibson - - - - - Ace - - - 1984-07-01T00:00:00+00:00 - - eng - - - - - - -
- - RATING: ★★★★
- - - TAGS: Cyberpunk, SciFi
- - - - - - -

Case is a washed-up console cowboy.

- -
- - - - -
- - - Project Hail Mary - urn:uuid:597dde46-29d6-4be1-bda7-b88211b8aefb - 2026-04-14T03:38:04+00:00 - - - Andy Weir - - - - - Ballantine - - - 2021-05-04T00:00:00+00:00 - - eng - - - - - - -
- - RATING: ★★★★
- - - TAGS: Recent, SciFi
- - - - - - -

A lone astronaut must save humanity.

- -
- - - - -
- - - - -
\ No newline at end of file diff --git a/src/lib/adapters/calibre/__tests__/fixtures/opds-new.xml b/src/lib/adapters/calibre/__tests__/fixtures/opds-new.xml deleted file mode 100644 index 02c5a887..00000000 --- a/src/lib/adapters/calibre/__tests__/fixtures/opds-new.xml +++ /dev/null @@ -1,284 +0,0 @@ - - - /static/favicon.ico - urn:uuid:2853dacf-ed79-42f5-8e8a-a7bb3d1ae6a2 - 2026-04-13T21:07:21+00:00 - - - - - - - - - Calibre-Web - - Calibre-Web - https://github.com/janeczku/calibre-web - - - - - - Project Hail Mary - urn:uuid:597dde46-29d6-4be1-bda7-b88211b8aefb - 2026-04-14T03:38:04+00:00 - - - Andy Weir - - - - - Ballantine - - - 2021-05-04T00:00:00+00:00 - - eng - - - - - - -
- - RATING: ★★★★
- - - TAGS: Recent, SciFi
- - - - - - -

A lone astronaut must save humanity.

- -
- - - - -
- - - Neuromancer - urn:uuid:9d8f7157-c055-4afc-a737-969c0105e7b7 - 2026-04-14T03:38:04+00:00 - - - William Gibson - - - - - Ace - - - 1984-07-01T00:00:00+00:00 - - eng - - - - - - -
- - RATING: ★★★★
- - - TAGS: Cyberpunk, SciFi
- - - - - - -

Case is a washed-up console cowboy.

- -
- - - - -
- - - Dune - urn:uuid:02a148e6-9d58-4000-9818-d6b8f71c7abf - 2026-04-14T03:38:04+00:00 - - - Frank Herbert - - - - - Chilton - - - 1965-08-01T00:00:00+00:00 - - eng - - - - - - -
- - RATING: ★★★★
- - - TAGS: Classic, SciFi
- - - SERIES: Dune [1]
- - - - - -

Desert planet of Arrakis.

- -
- - - - -
- - - The Fellowship of the Ring - urn:uuid:50f0e269-5d3f-4c40-8688-ae4a5827a962 - 2026-04-14T03:38:04+00:00 - - - J.R.R. Tolkien - - - - - Allen & Unwin - - - 1954-07-29T00:00:00+00:00 - - eng - - - - - - -
- - RATING: ★★★★★
- - - TAGS: Epic, Fantasy
- - - SERIES: Middle-earth [2]
- - - - - -

Frodo inherits the One Ring.

- -
- - - - -
- - - The Hobbit - urn:uuid:b57862dd-5e0d-4aa3-bf11-8ebbb74c179b - 2026-04-14T03:38:04+00:00 - - - J.R.R. Tolkien - - - - - Allen & Unwin - - - 1937-09-21T00:00:00+00:00 - - eng - - - - - - -
- - RATING: ★★★★★
- - - TAGS: Classic, Fantasy
- - - SERIES: Middle-earth [1]
- - - - - -

A hobbit's unexpected journey.

- -
- - - - -
- - - - -
\ No newline at end of file diff --git a/src/lib/adapters/calibre/__tests__/fixtures/opds-rated.xml b/src/lib/adapters/calibre/__tests__/fixtures/opds-rated.xml deleted file mode 100644 index 4ff0bcb8..00000000 --- a/src/lib/adapters/calibre/__tests__/fixtures/opds-rated.xml +++ /dev/null @@ -1,135 +0,0 @@ - - - /static/favicon.ico - urn:uuid:2853dacf-ed79-42f5-8e8a-a7bb3d1ae6a2 - 2026-04-13T21:07:22+00:00 - - - - - - - - - Calibre-Web - - Calibre-Web - https://github.com/janeczku/calibre-web - - - - - - The Fellowship of the Ring - urn:uuid:50f0e269-5d3f-4c40-8688-ae4a5827a962 - 2026-04-14T03:38:04+00:00 - - - J.R.R. Tolkien - - - - - Allen & Unwin - - - 1954-07-29T00:00:00+00:00 - - eng - - - - - - -
- - RATING: ★★★★★
- - - TAGS: Epic, Fantasy
- - - SERIES: Middle-earth [2]
- - - - - -

Frodo inherits the One Ring.

- -
- - - - -
- - - The Hobbit - urn:uuid:b57862dd-5e0d-4aa3-bf11-8ebbb74c179b - 2026-04-14T03:38:04+00:00 - - - J.R.R. Tolkien - - - - - Allen & Unwin - - - 1937-09-21T00:00:00+00:00 - - eng - - - - - - -
- - RATING: ★★★★★
- - - TAGS: Classic, Fantasy
- - - SERIES: Middle-earth [1]
- - - - - -

A hobbit's unexpected journey.

- -
- - - - -
- - - - -
\ No newline at end of file diff --git a/src/lib/adapters/calibre/__tests__/fixtures/opds-readbooks.xml b/src/lib/adapters/calibre/__tests__/fixtures/opds-readbooks.xml deleted file mode 100644 index 519b49b6..00000000 --- a/src/lib/adapters/calibre/__tests__/fixtures/opds-readbooks.xml +++ /dev/null @@ -1,84 +0,0 @@ - - - /static/favicon.ico - urn:uuid:2853dacf-ed79-42f5-8e8a-a7bb3d1ae6a2 - 2026-04-13T21:09:27+00:00 - - - - - - - - - Calibre-Web - - Calibre-Web - https://github.com/janeczku/calibre-web - - - - - - The Hobbit - urn:uuid:b57862dd-5e0d-4aa3-bf11-8ebbb74c179b - 2026-04-14T03:38:04+00:00 - - - J.R.R. Tolkien - - - - - Allen & Unwin - - - 1937-09-21T00:00:00+00:00 - - eng - - - - - - -
- - RATING: ★★★★★
- - - TAGS: Classic, Fantasy
- - - SERIES: Middle-earth [1]
- - - - - -

A hobbit's unexpected journey.

- -
- - - - -
- - - - -
\ No newline at end of file diff --git a/src/lib/adapters/calibre/__tests__/fixtures/opds-root.xml b/src/lib/adapters/calibre/__tests__/fixtures/opds-root.xml deleted file mode 100644 index 5236774b..00000000 --- a/src/lib/adapters/calibre/__tests__/fixtures/opds-root.xml +++ /dev/null @@ -1,149 +0,0 @@ - - - /static/favicon.ico - urn:uuid:2853dacf-ed79-42f5-8e8a-a7bb3d1ae6a2 - 2026-04-13T21:07:21+00:00 - - - - - Calibre-Web - - Calibre-Web - https://github.com/janeczku/calibre-web - - - Alphabetical Books - - /opds/books - 2026-04-13T21:07:21+00:00 - Books sorted alphabetically - - - - Hot Books - - /opds/hot - 2026-04-13T21:07:21+00:00 - Popular publications from this catalog based on Downloads. - - - - - Top Rated Books - - /opds/rated - 2026-04-13T21:07:21+00:00 - Popular publications from this catalog based on Rating. - - - - - Recently added Books - - /opds/new - 2026-04-13T21:07:21+00:00 - The latest Books - - - - - Random Books - - /opds/discover - 2026-04-13T21:07:21+00:00 - Show Random Books - - - - - Read Books - - /opds/readbooks - 2026-04-13T21:07:21+00:00 - Read Books - - - Unread Books - - /opds/unreadbooks - 2026-04-13T21:07:21+00:00 - Unread Books - - - - - Authors - - /opds/author - 2026-04-13T21:07:21+00:00 - Books ordered by Author - - - - - Publishers - - /opds/publisher - 2026-04-13T21:07:21+00:00 - Books ordered by publisher - - - - - Categories - - /opds/category - 2026-04-13T21:07:21+00:00 - Books ordered by category - - - - - Series - - /opds/series - 2026-04-13T21:07:21+00:00 - Books ordered by series - - - - - Languages - - /opds/language/ - 2026-04-13T21:07:21+00:00 - Books ordered by Languages - - - - - Ratings - - /opds/ratings - 2026-04-13T21:07:21+00:00 - Books ordered by Rating - - - - - File formats - - /opds/formats - 2026-04-13T21:07:21+00:00 - Books ordered by file formats - - - - - Shelves - - /opds/shelfindex - 2026-04-13T21:07:21+00:00 - Books organized in shelves - - - \ No newline at end of file diff --git a/src/lib/adapters/calibre/__tests__/fixtures/opds-search-hobbit.xml b/src/lib/adapters/calibre/__tests__/fixtures/opds-search-hobbit.xml deleted file mode 100644 index f509a78a..00000000 --- a/src/lib/adapters/calibre/__tests__/fixtures/opds-search-hobbit.xml +++ /dev/null @@ -1,84 +0,0 @@ - - - /static/favicon.ico - urn:uuid:2853dacf-ed79-42f5-8e8a-a7bb3d1ae6a2 - 2026-04-13T21:07:22+00:00 - - - - - - - - - Calibre-Web - - Calibre-Web - https://github.com/janeczku/calibre-web - - - - - - The Hobbit - urn:uuid:b57862dd-5e0d-4aa3-bf11-8ebbb74c179b - 2026-04-14T03:38:04+00:00 - - - J.R.R. Tolkien - - - - - Allen & Unwin - - - 1937-09-21T00:00:00+00:00 - - eng - - - - - - -
- - RATING: ★★★★★
- - - TAGS: Classic, Fantasy
- - - SERIES: Middle-earth [1]
- - - - - -

A hobbit's unexpected journey.

- -
- - - - -
- - - - -
\ No newline at end of file diff --git a/src/lib/adapters/calibre/__tests__/fixtures/opds-search-id-1.xml b/src/lib/adapters/calibre/__tests__/fixtures/opds-search-id-1.xml deleted file mode 100644 index 07153068..00000000 --- a/src/lib/adapters/calibre/__tests__/fixtures/opds-search-id-1.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - /static/favicon.ico - urn:uuid:2853dacf-ed79-42f5-8e8a-a7bb3d1ae6a2 - 2026-04-13T21:07:22+00:00 - - - - - - - - - Calibre-Web - - Calibre-Web - https://github.com/janeczku/calibre-web - - - - - - \ No newline at end of file diff --git a/src/lib/adapters/calibre/__tests__/fixtures/opds-unreadbooks.xml b/src/lib/adapters/calibre/__tests__/fixtures/opds-unreadbooks.xml deleted file mode 100644 index 2101fe79..00000000 --- a/src/lib/adapters/calibre/__tests__/fixtures/opds-unreadbooks.xml +++ /dev/null @@ -1,233 +0,0 @@ - - - /static/favicon.ico - urn:uuid:2853dacf-ed79-42f5-8e8a-a7bb3d1ae6a2 - 2026-04-13T21:09:27+00:00 - - - - - - - - - Calibre-Web - - Calibre-Web - https://github.com/janeczku/calibre-web - - - - - - The Fellowship of the Ring - urn:uuid:50f0e269-5d3f-4c40-8688-ae4a5827a962 - 2026-04-14T03:38:04+00:00 - - - J.R.R. Tolkien - - - - - Allen & Unwin - - - 1954-07-29T00:00:00+00:00 - - eng - - - - - - -
- - RATING: ★★★★★
- - - TAGS: Epic, Fantasy
- - - SERIES: Middle-earth [2]
- - - - - -

Frodo inherits the One Ring.

- -
- - - - -
- - - Dune - urn:uuid:02a148e6-9d58-4000-9818-d6b8f71c7abf - 2026-04-14T03:38:04+00:00 - - - Frank Herbert - - - - - Chilton - - - 1965-08-01T00:00:00+00:00 - - eng - - - - - - -
- - RATING: ★★★★
- - - TAGS: Classic, SciFi
- - - SERIES: Dune [1]
- - - - - -

Desert planet of Arrakis.

- -
- - - - -
- - - Neuromancer - urn:uuid:9d8f7157-c055-4afc-a737-969c0105e7b7 - 2026-04-14T03:38:04+00:00 - - - William Gibson - - - - - Ace - - - 1984-07-01T00:00:00+00:00 - - eng - - - - - - -
- - RATING: ★★★★
- - - TAGS: Cyberpunk, SciFi
- - - - - - -

Case is a washed-up console cowboy.

- -
- - - - -
- - - Project Hail Mary - urn:uuid:597dde46-29d6-4be1-bda7-b88211b8aefb - 2026-04-14T03:38:04+00:00 - - - Andy Weir - - - - - Ballantine - - - 2021-05-04T00:00:00+00:00 - - eng - - - - - - -
- - RATING: ★★★★
- - - TAGS: Recent, SciFi
- - - - - - -

A lone astronaut must save humanity.

- -
- - - - -
- - - - -
\ No newline at end of file diff --git a/src/lib/adapters/calibre/__tests__/live-smoke.ts b/src/lib/adapters/calibre/__tests__/live-smoke.ts deleted file mode 100644 index 82625f97..00000000 --- a/src/lib/adapters/calibre/__tests__/live-smoke.ts +++ /dev/null @@ -1,196 +0,0 @@ -#!/usr/bin/env tsx -/** - * Live integration smoke test. NOT part of the automated suite — this hits a - * real Calibre-Web instance and asserts the full adapter surface works end to - * end. Run manually: - * - * pnpm tsx src/lib/adapters/calibre/__tests__/live-smoke.ts - * - * Requires a Calibre-Web instance at CALIBRE_URL (default http://localhost:8083) - * with credentials CALIBRE_USER / CALIBRE_PASS (default admin/admin123) and a - * seeded library. - */ -import { calibreAdapter, getCalibreAuthors, getCalibreCategories, getCalibreSeries, toggleReadStatus, downloadBook } from '../../calibre'; -import type { ServiceConfig } from '../../types'; - -const URL = process.env.CALIBRE_URL ?? 'http://localhost:8083'; -const USER = process.env.CALIBRE_USER ?? 'admin'; -const PASS = process.env.CALIBRE_PASS ?? 'admin123'; - -const config: ServiceConfig = { - id: 'smoke-calibre', - name: 'Smoke Calibre', - type: 'calibre', - url: URL, - username: USER, - password: PASS, - enabled: true -}; - -const pass: string[] = []; -const fail: string[] = []; -async function step(label: string, fn: () => Promise): Promise { - try { - const result = await fn(); - pass.push(label); - console.log(`✓ ${label}`); - if (process.env.VERBOSE) console.log(' →', JSON.stringify(result, null, 2).slice(0, 500)); - } catch (err) { - fail.push(`${label}: ${(err as Error).message}`); - console.error(`✗ ${label}: ${(err as Error).message}`); - } -} - -async function main(): Promise { - await step('ping', async () => { - const health = await calibreAdapter.ping!(config); - if (!health.online) throw new Error(health.error ?? 'ping returned offline'); - return health; - }); - - await step('getRecentlyAdded', async () => { - const items = await calibreAdapter.getRecentlyAdded!(config); - if (items.length === 0) throw new Error('no recent items'); - if (items[0].type !== 'book') throw new Error(`unexpected type: ${items[0].type}`); - return { count: items.length, first: items[0].title }; - }); - - await step('getContinueWatching', async () => { - const items = await calibreAdapter.getContinueWatching!(config); - // Calibre intentionally returns [] here — per-user reading progress - // comes from `play_sessions`, not the adapter. See the 2026-04-17 - // player alignment plan (#12). - if (items.length !== 0) throw new Error(`expected empty, got ${items.length}`); - return { count: items.length }; - }); - - await step('search() — exercise the search path with whatever is seeded', async () => { - // Pick a partial that should match nearly any test fixture. If the - // library is empty, getRecentlyAdded above will have already failed, - // so this is only reached when there's something to search. - const recent = await calibreAdapter.getRecentlyAdded!(config); - const needle = recent[0]?.title?.split(/\s+/)[0] ?? 'the'; - const result = await calibreAdapter.search!(config, needle); - if (result.items.length === 0) throw new Error(`no search results for "${needle}"`); - return { needle, count: result.items.length }; - }); - - await step('getLibrary (default)', async () => { - const { items, total } = await calibreAdapter.getLibrary!(config, { limit: 50 }); - if (total !== items.length && total === 0) throw new Error('total=0'); - return { count: items.length, total }; - }); - - await step('getLibrary (sortBy: added)', async () => { - const { items } = await calibreAdapter.getLibrary!(config, { limit: 10, sortBy: 'added' }); - return { count: items.length }; - }); - - await step('getLibrary (sortBy: rating)', async () => { - const { items } = await calibreAdapter.getLibrary!(config, { limit: 10, sortBy: 'rating' }); - return { count: items.length }; - }); - - await step('getItem(1)', async () => { - const item = await calibreAdapter.getItem!(config, '1'); - if (!item) throw new Error('item not found'); - if (item.type !== 'book') throw new Error('wrong type'); - return { title: item.title, formatCount: item.metadata?.formatCount }; - }); - - await step('enrichItem(formats)', async () => { - const item = await calibreAdapter.getItem!(config, '1'); - if (!item) throw new Error('item not found'); - const enriched = await calibreAdapter.enrichItem!(config, item, 'formats'); - const formats = (enriched.metadata as { formats?: unknown[] } | undefined)?.formats; - if (!Array.isArray(formats) || formats.length === 0) { - throw new Error('no formats returned'); - } - return formats; - }); - - await step('enrichItem(related)', async () => { - const item = await calibreAdapter.getItem!(config, '1'); - if (!item) throw new Error('item not found'); - const enriched = await calibreAdapter.enrichItem!(config, item, 'related'); - const related = (enriched.metadata as { related?: unknown } | undefined)?.related; - if (!related) throw new Error('no related returned'); - return related; - }); - - await step('getServiceData(series)', async () => { - const series = await getCalibreSeries(config); - return { count: series.length, names: series.map((s) => s.name) }; - }); - - await step('getServiceData(authors)', async () => { - const authors = await getCalibreAuthors(config); - return { count: authors.length }; - }); - - await step('getServiceData(categories)', async () => { - const cats = await getCalibreCategories(config); - return { count: cats.length }; - }); - - await step('authenticateUser(valid)', async () => { - const res = await calibreAdapter.authenticateUser!(config, USER, PASS); - if (res.externalUsername !== USER) throw new Error('wrong username'); - return res; - }); - - await step('authenticateUser(invalid) — should throw', async () => { - try { - await calibreAdapter.authenticateUser!(config, USER, 'wrong-password'); - throw new Error('expected auth failure but got success'); - } catch (err) { - const msg = (err as Error).message.toLowerCase(); - // Accept either the wrapped AdapterAuthError copy or the raw - // OPDS layer's "authentication failed" — both are correct - // signals from the adapter's POV. - if (msg.includes('invalid') || msg.includes('authentication failed')) { - return 'correctly rejected'; - } - throw err; - } - }); - - await step('getImageHeaders', async () => { - const headers = await calibreAdapter.getImageHeaders!(config); - if (!headers.Authorization?.startsWith('Basic ')) throw new Error('expected Basic auth header'); - return headers; - }); - - await step('toggleReadStatus(1) — session-cookie write path', async () => { - const ok = await toggleReadStatus(config, '1'); - if (!ok) throw new Error('toggle returned false'); - // toggle back so the fixture state is restored - await toggleReadStatus(config, '1'); - return 'toggled and restored'; - }); - - await step('setItemStatus({read: true})', async () => { - await calibreAdapter.setItemStatus!(config, '1', { read: true }); - await calibreAdapter.setItemStatus!(config, '1', { read: false }); // restore - return 'ok'; - }); - - await step('downloadContent(1, epub)', async () => { - const res = await downloadBook(config, '1', 'epub'); - if (!res.ok) throw new Error(`download failed: ${res.status}`); - const buf = await res.arrayBuffer(); - return { status: res.status, bytes: buf.byteLength }; - }); - - console.log(`\n${pass.length} passed, ${fail.length} failed`); - if (fail.length > 0) { - console.error('\nFailures:'); - for (const f of fail) console.error(' ' + f); - process.exit(1); - } -} - -main().catch((err) => { - console.error('FATAL:', err); - process.exit(1); -}); diff --git a/src/lib/adapters/calibre/__tests__/opds-parse.test.ts b/src/lib/adapters/calibre/__tests__/opds-parse.test.ts deleted file mode 100644 index 50b3ed66..00000000 --- a/src/lib/adapters/calibre/__tests__/opds-parse.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { parseOpdsFeed } from '../opds-parse'; - -const FIX = join(__dirname, 'fixtures'); -const read = (name: string) => readFileSync(join(FIX, name), 'utf-8'); - -describe('parseOpdsFeed', () => { - it('parses /opds root as a navigation feed with zero book entries', () => { - const feed = parseOpdsFeed(read('opds-root.xml')); - // Root is a navigation feed — its entries are subsection links, not books. - // The parser only returns entries with acquisition links or valid book ids. - expect(feed.entries).toEqual([]); - }); - - it('parses /opds/new into book entries with full metadata', () => { - const feed = parseOpdsFeed(read('opds-new.xml')); - expect(feed.entries.length).toBeGreaterThan(0); - const byTitle = new Map(feed.entries.map((e) => [e.title, e])); - const hobbit = byTitle.get('The Hobbit'); - expect(hobbit).toBeDefined(); - if (!hobbit) throw new Error(); - expect(hobbit.authors).toContain('J.R.R. Tolkien'); - expect(hobbit.publishers).toContain('Allen & Unwin'); - expect(hobbit.id).toBe('1'); - expect(hobbit.uuid).toMatch(/^[a-f0-9-]{36}$/); - expect(hobbit.categories).toEqual(expect.arrayContaining(['Fantasy', 'Classic'])); - expect(hobbit.published?.getFullYear()).toBe(1937); - expect(hobbit.acquisitions.length).toBeGreaterThan(0); - const epub = hobbit.acquisitions.find((a) => a.format === 'EPUB'); - expect(epub).toBeDefined(); - expect(epub?.href).toMatch(/\/opds\/download\/1\/epub\//); - expect(epub?.mimeType).toBe('application/epub+zip'); - expect(epub?.length).toBe(30); - }); - - it('parses ratings from content HTML', () => { - const feed = parseOpdsFeed(read('opds-new.xml')); - const withRating = feed.entries.filter((e) => e.ratingStars !== undefined); - expect(withRating.length).toBeGreaterThan(0); - for (const e of withRating) { - expect(e.ratingStars).toBeGreaterThanOrEqual(1); - expect(e.ratingStars).toBeLessThanOrEqual(5); - } - }); - - it('strips HTML from description', () => { - const feed = parseOpdsFeed(read('opds-new.xml')); - const hobbit = feed.entries.find((e) => e.title === 'The Hobbit'); - expect(hobbit?.description).toBeDefined(); - expect(hobbit?.description).not.toContain('<'); - expect(hobbit?.description).not.toContain('RATING'); - expect(hobbit?.description).not.toContain('TAGS'); - }); - - it('parses series name and index from SERIES: Name [Index] content', () => { - const feed = parseOpdsFeed(read('opds-new.xml')); - const fellowship = feed.entries.find((e) => e.title === 'The Fellowship of the Ring'); - expect(fellowship?.series).toBe('Middle-earth'); - expect(fellowship?.seriesIndex).toBe(2); - const hobbit = feed.entries.find((e) => e.title === 'The Hobbit'); - expect(hobbit?.series).toBe('Middle-earth'); - expect(hobbit?.seriesIndex).toBe(1); - const dune = feed.entries.find((e) => e.title === 'Dune'); - expect(dune?.series).toBe('Dune'); - }); - - it('parses /opds/books/letter/00 as the full library', () => { - const feed = parseOpdsFeed(read('opds-books-all.xml')); - expect(feed.entries.length).toBe(5); - const titles = feed.entries.map((e) => e.title).sort(); - expect(titles).toEqual([ - 'Dune', - 'Neuromancer', - 'Project Hail Mary', - 'The Fellowship of the Ring', - 'The Hobbit' - ]); - }); - - it('parses /opds/search results', () => { - const feed = parseOpdsFeed(read('opds-search-hobbit.xml')); - expect(feed.entries.length).toBe(1); - expect(feed.entries[0].title).toBe('The Hobbit'); - }); - - it('returns empty for /opds/search/id:1 — OPDS search does not support Calibre field syntax', () => { - // Documenting the Calibre-Web quirk: /opds/search/{query} is plain text only. - // `id:N`, `title:X`, etc. all return empty feeds. This fixture exists to pin - // that behavior so we don't accidentally reintroduce a broken fast-path. - const feed = parseOpdsFeed(read('opds-search-id-1.xml')); - expect(feed.entries).toEqual([]); - }); - - it('parses /opds/unreadbooks (for getContinueWatching)', () => { - const feed = parseOpdsFeed(read('opds-unreadbooks.xml')); - // All 5 seeded books are unread at capture time (toggled back before fixture grab - // or captured before toggle). We assert structural correctness, not exact count. - expect(feed.entries.length).toBeGreaterThanOrEqual(1); - for (const e of feed.entries) { - expect(e.id).toMatch(/^\d+$/); - expect(e.title).toBeTruthy(); - } - }); - - it('parses /opds/readbooks', () => { - const feed = parseOpdsFeed(read('opds-readbooks.xml')); - // readbooks may be empty if no books are marked read at capture time — structural check only - for (const e of feed.entries) { - expect(e.id).toMatch(/^\d+$/); - expect(e.title).toBeTruthy(); - } - }); - - it('does not throw on empty or malformed input for the error path we care about', () => { - expect(() => parseOpdsFeed('')).not.toThrow(); - const feed = parseOpdsFeed(''); - expect(feed.entries).toEqual([]); - }); - - it('throws a structured error on invalid XML', () => { - expect(() => parseOpdsFeed('(); - const out: CalibreFormat[] = []; - for (const acq of entry.acquisitions) { - const name = acq.format.toUpperCase(); - if (seen.has(name)) continue; - seen.add(name); - out.push({ - name, - downloadUrl: `/api/books/${entry.id}/download/${name.toLowerCase()}` - }); - } - return out; -} - -function proxiedCoverUrl(config: ServiceConfig, entry: OpdsEntry): string | undefined { - const href = entry.coverHref ?? entry.thumbHref ?? (entry.id ? `/opds/cover/${entry.id}` : undefined); - if (!href) return undefined; - return `/api/media/image?service=${encodeURIComponent(config.id)}&path=${encodeURIComponent(href)}`; -} - -export function opdsEntryToUnifiedMedia(config: ServiceConfig, entry: OpdsEntry): UnifiedMedia { - const year = entry.published ? entry.published.getFullYear() : undefined; - const rating = entry.ratingStars !== undefined ? entry.ratingStars * 2 : undefined; - const formatCount = entry.acquisitions.length; - - return { - id: `${entry.id}:${config.id}`, - sourceId: entry.id, - serviceId: config.id, - serviceType: 'calibre', - type: 'book', - title: entry.title, - description: entry.description, - poster: proxiedCoverUrl(config, entry), - year: year && !isNaN(year) ? year : undefined, - rating, - genres: entry.categories, - status: 'available', - metadata: { - calibreId: entry.id, - uuid: entry.uuid, - author: entry.authors.join(', ') || undefined, - authorSort: entry.authors.join(', ') || undefined, - publisher: entry.publishers.join(', ') || undefined, - language: entry.language && entry.language !== 'Unknown' ? entry.language : undefined, - seriesName: entry.series, - seriesIndex: entry.seriesIndex, - formatCount, - formats: acquisitionsToFormats(entry) - }, - actionLabel: 'Read', - actionUrl: `/books/read/${entry.id}?service=${config.id}`, - streamUrl: formatCount > 0 ? `/api/books/${entry.id}/read` : undefined - }; -} diff --git a/src/lib/adapters/calibre/opds-client.ts b/src/lib/adapters/calibre/opds-client.ts deleted file mode 100644 index 16ef178d..00000000 --- a/src/lib/adapters/calibre/opds-client.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { ServiceConfig, UserCredential } from '../types'; -import { parseOpdsFeed } from './opds-parse'; -import type { OpdsEntry, OpdsFeed } from './types'; - -const DEFAULT_TIMEOUT_MS = 10_000; -const MAX_PAGES = 100; - -export function opdsAuthHeader(config: ServiceConfig, userCred?: UserCredential): string { - const user = userCred?.externalUsername ?? config.username ?? ''; - const pass = userCred?.accessToken ?? config.password ?? ''; - if (!user || !pass) { - throw new Error('Calibre-Web adapter: username and password are required for OPDS auth'); - } - const token = Buffer.from(`${user}:${pass}`, 'utf-8').toString('base64'); - return `Basic ${token}`; -} - -async function rawOpdsFetch( - config: ServiceConfig, - path: string, - userCred: UserCredential | undefined, - timeoutMs: number -): Promise { - const url = path.startsWith('http') ? path : `${config.url}${path}`; - const res = await fetch(url, { - headers: { - Authorization: opdsAuthHeader(config, userCred), - Accept: 'application/atom+xml,application/xml;q=0.9,*/*;q=0.8' - }, - signal: AbortSignal.timeout(timeoutMs) - }); - if (res.status === 401) { - throw new Error('Calibre-Web authentication failed — check username and password'); - } - if (res.status === 403) { - throw new Error('Calibre-Web permission denied — user lacks required role'); - } - if (!res.ok) { - throw new Error(`Calibre-Web OPDS ${path} → HTTP ${res.status}`); - } - return res.text(); -} - -export async function opdsFetch( - config: ServiceConfig, - path: string, - userCred?: UserCredential, - opts?: { timeoutMs?: number } -): Promise { - const xml = await rawOpdsFetch(config, path, userCred, opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS); - return parseOpdsFeed(xml); -} - -/** - * Walk a paginated OPDS feed until no rel="next" link remains or MAX_PAGES is hit. - * Used by library-wide aggregations (series, authors, categories, all-books). - */ -export async function opdsFetchAllPages( - config: ServiceConfig, - startPath: string, - userCred?: UserCredential -): Promise { - const entries: OpdsEntry[] = []; - let path: string | undefined = startPath; - let pages = 0; - const seen = new Set(); - while (path && pages < MAX_PAGES) { - if (seen.has(path)) break; - seen.add(path); - const feed = await opdsFetch(config, path, userCred); - entries.push(...feed.entries); - path = feed.nextHref; - pages++; - } - return entries; -} - -/** Ping: a single /opds GET that just verifies auth + server responds with a feed. */ -export async function opdsPing(config: ServiceConfig, userCred?: UserCredential): Promise { - await opdsFetch(config, '/opds', userCred, { timeoutMs: 8000 }); -} diff --git a/src/lib/adapters/calibre/opds-parse.ts b/src/lib/adapters/calibre/opds-parse.ts deleted file mode 100644 index 15473a8d..00000000 --- a/src/lib/adapters/calibre/opds-parse.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { XMLParser } from 'fast-xml-parser'; -import type { OpdsAcquisition, OpdsEntry, OpdsFeed } from './types'; - -const parser = new XMLParser({ - ignoreAttributes: false, - attributeNamePrefix: '@_', - allowBooleanAttributes: true, - parseAttributeValue: false, - trimValues: true, - removeNSPrefix: true, - isArray: (name) => name === 'entry' || name === 'link' || name === 'category' || name === 'author' || name === 'publisher' || name === 'language' -}); - -const ACQUISITION_REL = 'http://opds-spec.org/acquisition'; -const COVER_REL = 'http://opds-spec.org/image'; -const THUMB_REL = 'http://opds-spec.org/image/thumbnail'; - -function toStr(v: unknown): string | undefined { - if (v === null || v === undefined) return undefined; - if (typeof v === 'string') return v.trim() || undefined; - if (typeof v === 'number' || typeof v === 'boolean') return String(v); - if (typeof v === 'object' && '#text' in (v as Record)) { - return toStr((v as Record)['#text']); - } - return undefined; -} - -function toDate(v: unknown): Date | undefined { - const s = toStr(v); - if (!s) return undefined; - const d = new Date(s); - return isNaN(d.getTime()) ? undefined : d; -} - -function extractLinks(entry: Record): Array> { - const raw = entry.link; - if (!raw) return []; - const arr = Array.isArray(raw) ? raw : [raw]; - return arr.map((l) => { - const attrs: Record = {}; - if (typeof l === 'object' && l !== null) { - for (const [k, v] of Object.entries(l as Record)) { - if (k.startsWith('@_') && typeof v === 'string') attrs[k.slice(2)] = v; - } - } - return attrs; - }); -} - -function extractCategories(entry: Record): string[] { - const raw = entry.category; - if (!raw) return []; - const arr = Array.isArray(raw) ? raw : [raw]; - const seen = new Set(); - const out: string[] = []; - for (const c of arr) { - const term = typeof c === 'object' && c !== null - ? (c as Record)['@_term'] ?? (c as Record)['@_label'] - : undefined; - const s = toStr(term); - if (s && !seen.has(s)) { - seen.add(s); - out.push(s); - } - } - return out; -} - -function extractNames(raw: unknown): string[] { - if (!raw) return []; - const arr = Array.isArray(raw) ? raw : [raw]; - const out: string[] = []; - for (const item of arr) { - if (typeof item === 'string') { - const s = item.trim(); - if (s) out.push(s); - } else if (typeof item === 'object' && item !== null) { - const name = (item as Record).name; - const s = toStr(name); - if (s) out.push(s); - } - } - return out; -} - -function extractContent(entry: Record): string | undefined { - const raw = entry.content ?? entry.summary; - if (!raw) return undefined; - if (typeof raw === 'string') return raw; - if (typeof raw === 'object' && raw !== null) { - return toStr((raw as Record)['#text']) ?? toStrDeep(raw); - } - return undefined; -} - -function toStrDeep(v: unknown): string | undefined { - if (v === null || v === undefined) return undefined; - if (typeof v === 'string') return v; - if (typeof v === 'number') return String(v); - if (Array.isArray(v)) return v.map(toStrDeep).filter(Boolean).join(' '); - if (typeof v === 'object') { - const parts: string[] = []; - for (const [k, val] of Object.entries(v as Record)) { - if (k.startsWith('@_')) continue; - const s = toStrDeep(val); - if (s) parts.push(s); - } - return parts.join(' ').trim() || undefined; - } - return undefined; -} - -function parseRating(contentText: string | undefined): number | undefined { - if (!contentText) return undefined; - const match = contentText.match(/RATING:\s*([★☆]+)/); - if (!match) return undefined; - const stars = (match[1].match(/★/g) ?? []).length; - return stars > 0 ? stars : undefined; -} - -function parseDescription(contentText: string | undefined): string | undefined { - if (!contentText) return undefined; - // Strip the RATING / TAGS preamble and anything before the first

, then strip tags - const pMatch = contentText.match(/

([\s\S]*?)<\/p>/); - let body = pMatch ? pMatch[1] : contentText; - body = body - .replace(/<[^>]+>/g, ' ') - .replace(/RATING:\s*[★☆]+/g, '') - .replace(/TAGS:[^<\n]*/g, '') - .replace(/SERIES:[^<\n]*/g, '') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/"/g, '"') - .replace(/\s+/g, ' ') - .trim(); - return body || undefined; -} - -function parseSeries(contentText: string | undefined): { name: string; index?: number } | undefined { - if (!contentText) return undefined; - // Calibre-Web's feed.xml content HTML renders series as "SERIES: Name [Index]
". - // Verified against linuxserver/calibre-web 0.6.x — the index uses SQUARE brackets, - // not parentheses. - const match = contentText.match(/SERIES:\s*([^<[\n]+?)\s*(?:\[([0-9.]+)\])?\s*(?:<|$)/); - if (!match) return undefined; - const name = match[1].trim(); - if (!name) return undefined; - const index = match[2] ? parseFloat(match[2]) : undefined; - return { name, index }; -} - -function extractIdFromHref(href: string | undefined): string | undefined { - if (!href) return undefined; - // Matches /opds/download/{id}/{fmt}/ or /opds/cover/{id} or /opds/cover_N_N/{id} - const m = href.match(/\/(?:opds\/)?(?:download|cover|cover_\d+_\d+|thumb_\d+_\d+)\/(\d+)/); - return m?.[1]; -} - -function extractUuidFromId(idField: string | undefined): string | undefined { - if (!idField) return undefined; - const m = idField.match(/urn:uuid:([a-f0-9-]+)/i); - return m?.[1]; -} - -function parseAcquisitionLink(attrs: Record): OpdsAcquisition | undefined { - const rel = attrs.rel ?? ''; - if (!rel.startsWith(ACQUISITION_REL)) return undefined; - const href = attrs.href; - if (!href) return undefined; - let format = attrs.title ?? ''; - if (!format) { - // Fall back to extracting from href: /opds/download/1/epub/ - const m = href.match(/\/download\/\d+\/([^/]+)/); - format = m ? m[1].toUpperCase() : ''; - } - if (!format) return undefined; - return { - format: format.toUpperCase(), - href, - length: attrs.length ? parseInt(attrs.length, 10) : undefined, - mtime: attrs.mtime ? new Date(attrs.mtime) : undefined, - mimeType: attrs.type ?? 'application/octet-stream' - }; -} - -function parseEntry(raw: Record): OpdsEntry | undefined { - const title = toStr(raw.title); - if (!title) return undefined; - - const idField = toStr(raw.id); - const uuid = extractUuidFromId(idField); - - const links = extractLinks(raw); - const acquisitions: OpdsAcquisition[] = []; - let coverHref: string | undefined; - let thumbHref: string | undefined; - let numericId: string | undefined; - - for (const attrs of links) { - const rel = attrs.rel ?? ''; - if (rel.startsWith(ACQUISITION_REL)) { - const acq = parseAcquisitionLink(attrs); - if (acq) { - acquisitions.push(acq); - numericId ??= extractIdFromHref(acq.href); - } - } else if (rel === COVER_REL) { - coverHref = attrs.href; - numericId ??= extractIdFromHref(coverHref); - } else if (rel === THUMB_REL) { - thumbHref = attrs.href; - numericId ??= extractIdFromHref(thumbHref); - } - } - - if (!numericId || !uuid) return undefined; - - const authors = extractNames(raw.author); - const publishers = extractNames(raw.publisher); - const categories = extractCategories(raw); - const language = toStr(raw.language); - const published = toDate(raw.published); - const updated = toDate(raw.updated); - - const contentText = extractContent(raw); - const ratingStars = parseRating(contentText); - const description = parseDescription(contentText); - const seriesInfo = parseSeries(contentText); - - return { - id: numericId, - uuid, - title, - authors, - publishers, - language, - published, - updated, - categories, - series: seriesInfo?.name, - seriesIndex: seriesInfo?.index, - ratingStars, - description, - coverHref, - thumbHref, - acquisitions - }; -} - -export function parseOpdsFeed(xml: string): OpdsFeed { - let doc: Record; - try { - doc = parser.parse(xml) as Record; - } catch (err) { - throw new Error(`OPDS XML parse failed: ${(err as Error).message}`); - } - - const feed = (doc.feed ?? {}) as Record; - const rawEntries = feed.entry; - const entryArr = Array.isArray(rawEntries) ? rawEntries : rawEntries ? [rawEntries] : []; - const entries: OpdsEntry[] = []; - for (const raw of entryArr) { - if (typeof raw !== 'object' || raw === null) continue; - const parsed = parseEntry(raw as Record); - if (parsed) entries.push(parsed); - } - - // Find rel="next" link on the feed itself - const feedLinks = extractLinks(feed); - let nextHref: string | undefined; - for (const attrs of feedLinks) { - if (attrs.rel === 'next' && attrs.href) { - nextHref = attrs.href; - break; - } - } - - const totalResults = typeof feed.totalResults === 'number' - ? feed.totalResults - : typeof feed.totalResults === 'string' - ? parseInt(feed.totalResults as string, 10) || undefined - : undefined; - - return { totalResults, nextHref, entries }; -} diff --git a/src/lib/adapters/calibre/session-cache.ts b/src/lib/adapters/calibre/session-cache.ts deleted file mode 100644 index eef4c10e..00000000 --- a/src/lib/adapters/calibre/session-cache.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { ServiceConfig, UserCredential } from '../types'; - -interface CachedSession { - cookie: string; - csrfToken?: string; - expiresAt: number; -} - -const SESSION_TTL_MS = 55 * 60_000; // Flask default session is 60min; refresh early -const cache = new Map(); - -function cacheKey(config: ServiceConfig, userCred?: UserCredential): string { - const user = userCred?.externalUsername ?? config.username ?? ''; - return `${config.id}:${user}`; -} - -export function getCachedSession(config: ServiceConfig, userCred?: UserCredential): CachedSession | undefined { - const entry = cache.get(cacheKey(config, userCred)); - if (!entry) return undefined; - if (entry.expiresAt <= Date.now()) { - cache.delete(cacheKey(config, userCred)); - return undefined; - } - return entry; -} - -export function setCachedSession(config: ServiceConfig, cookie: string, userCred?: UserCredential): CachedSession { - const entry: CachedSession = { cookie, expiresAt: Date.now() + SESSION_TTL_MS }; - cache.set(cacheKey(config, userCred), entry); - return entry; -} - -export function setCachedCsrf(config: ServiceConfig, csrfToken: string, userCred?: UserCredential): void { - const entry = cache.get(cacheKey(config, userCred)); - if (entry) entry.csrfToken = csrfToken; -} - -export function invalidateSession(config: ServiceConfig, userCred?: UserCredential): void { - cache.delete(cacheKey(config, userCred)); -} diff --git a/src/lib/adapters/calibre/session-client.ts b/src/lib/adapters/calibre/session-client.ts deleted file mode 100644 index a0d9343d..00000000 --- a/src/lib/adapters/calibre/session-client.ts +++ /dev/null @@ -1,192 +0,0 @@ -import type { ServiceConfig, UserCredential } from '../types'; -import { getCachedSession, setCachedSession, setCachedCsrf, invalidateSession } from './session-cache'; - -const DEFAULT_TIMEOUT_MS = 10_000; - -function extractSetCookies(headers: Headers): string[] { - const maybeFn = (headers as unknown as { getSetCookie?: () => string[] }).getSetCookie; - if (typeof maybeFn === 'function') return maybeFn.call(headers); - const raw = headers.get('set-cookie'); - if (!raw) return []; - return raw.split(/,(?=\s*\w+=)/).map((c) => c.trim()); -} - -/** - * Merge Set-Cookie headers into a name→value jar. Prefix-agnostic — Calibre-Web - * uses {COOKIE_PREFIX}session and {COOKIE_PREFIX}remember_token, which the old - * prefix-based matcher missed on any install with a configured prefix. - */ -function mergeCookies(jar: Map, setCookies: string[]): void { - for (const sc of setCookies) { - const pair = sc.split(';')[0]?.trim(); - if (!pair) continue; - const eq = pair.indexOf('='); - if (eq < 1) continue; - jar.set(pair.substring(0, eq), pair.substring(eq + 1)); - } -} - -function serializeJar(jar: Map): string { - return Array.from(jar.entries()).map(([k, v]) => `${k}=${v}`).join('; '); -} - -function extractCsrfToken(html: string): string | undefined { - const match = html.match(/name="csrf_token"\s+value="([^"]+)"/); - return match?.[1]; -} - -function isHtmlLoginPage(contentType: string, body?: string): boolean { - if (contentType.includes('json')) return false; - if (!contentType.includes('html')) return false; - if (body && (body.includes('name="csrf_token"') && body.includes('/login'))) return true; - // If we only have content-type, err on the side of assuming it's a login page - return !body; -} - -async function performLogin(config: ServiceConfig, userCred?: UserCredential): Promise { - const user = userCred?.externalUsername ?? config.username ?? ''; - const pass = userCred?.accessToken ?? config.password ?? ''; - if (!user || !pass) { - throw new Error('Calibre-Web adapter: username and password are required for session auth'); - } - - const jar = new Map(); - - // Step 1: GET /login → CSRF token + initial session cookie - const loginPageRes = await fetch(`${config.url}/login`, { - redirect: 'manual', - signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS) - }); - mergeCookies(jar, extractSetCookies(loginPageRes.headers)); - const html = await loginPageRes.text(); - const csrf = extractCsrfToken(html); - if (!csrf) throw new Error('Calibre-Web: could not find csrf_token on /login page'); - - // Step 2: POST /login with form body - const body = new URLSearchParams({ - username: user, - password: pass, - csrf_token: csrf, - next: '/', - remember_me: 'on', - submit: '' - }); - const loginRes = await fetch(`${config.url}/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Cookie: serializeJar(jar) - }, - body: body.toString(), - redirect: 'manual', - signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS) - }); - if (loginRes.status !== 302) { - throw new Error('Calibre-Web login failed: wrong username or password'); - } - mergeCookies(jar, extractSetCookies(loginRes.headers)); - - const cookieHeader = serializeJar(jar); - if (!cookieHeader) throw new Error('Calibre-Web login: no cookies returned from server'); - return cookieHeader; -} - -export async function getSessionCookie( - config: ServiceConfig, - userCred?: UserCredential -): Promise { - const cached = getCachedSession(config, userCred); - if (cached) return cached.cookie; - const cookie = await performLogin(config, userCred); - setCachedSession(config, cookie, userCred); - return cookie; -} - -/** - * Fetch a fresh CSRF token for write endpoints. Calibre-Web's csrf_token is NOT - * rendered on the authed home page by default — it only appears on pages with - * forms AND (for the nav upload form) when uploads are globally enabled. The - * `/me` profile page is a reliable source: always logged-in-required, always - * has a form with csrf_token. - */ -export async function getCsrfToken( - config: ServiceConfig, - cookie: string, - userCred?: UserCredential -): Promise { - const cached = getCachedSession(config, userCred); - if (cached?.csrfToken) return cached.csrfToken; - - const res = await fetch(`${config.url}/me`, { - headers: { Cookie: cookie }, - signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS) - }); - if (!res.ok) throw new Error(`Calibre-Web /me → HTTP ${res.status} (could not fetch CSRF token)`); - const html = await res.text(); - const token = extractCsrfToken(html); - if (!token) throw new Error('Calibre-Web: could not find csrf_token on /me page'); - setCachedCsrf(config, token, userCred); - return token; -} - -/** - * POST to a session-protected endpoint. Auto-refreshes the session on - * detected expiry (HTML-login-page sniff), retrying once. - */ -export async function sessionPost( - config: ServiceConfig, - path: string, - init: { body?: BodyInit; headers?: Record } = {}, - userCred?: UserCredential -): Promise { - const doRequest = async (): Promise => { - const cookie = await getSessionCookie(config, userCred); - const csrf = await getCsrfToken(config, cookie, userCred); - return fetch(`${config.url}${path}`, { - method: 'POST', - headers: { - Cookie: cookie, - 'X-CSRFToken': csrf, - ...init.headers - }, - body: init.body, - redirect: 'manual', - signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS) - }); - }; - - let res = await doRequest(); - const contentType = res.headers.get('content-type') ?? ''; - if (isHtmlLoginPage(contentType) && res.status !== 302) { - const body = await res.clone().text(); - if (isHtmlLoginPage(contentType, body)) { - invalidateSession(config, userCred); - res = await doRequest(); - } - } - return res; -} - -/** GET a session-protected HTML page (for form scraping, e.g. /admin/user/new) */ -export async function sessionGet( - config: ServiceConfig, - path: string, - userCred?: UserCredential -): Promise { - const cookie = await getSessionCookie(config, userCred); - const res = await fetch(`${config.url}${path}`, { - headers: { Cookie: cookie }, - signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS) - }); - if (res.status === 401 || res.status === 403) { - invalidateSession(config, userCred); - const cookie2 = await getSessionCookie(config, userCred); - return fetch(`${config.url}${path}`, { - headers: { Cookie: cookie2 }, - signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS) - }); - } - return res; -} - -export { invalidateSession }; diff --git a/src/lib/adapters/calibre/types.ts b/src/lib/adapters/calibre/types.ts deleted file mode 100644 index 066c6842..00000000 --- a/src/lib/adapters/calibre/types.ts +++ /dev/null @@ -1,37 +0,0 @@ -export interface OpdsAcquisition { - format: string; - href: string; - length?: number; - mtime?: Date; - mimeType: string; -} - -export interface OpdsEntry { - id: string; - uuid: string; - title: string; - authors: string[]; - publishers: string[]; - language?: string; - published?: Date; - updated?: Date; - categories: string[]; - series?: string; - seriesIndex?: number; - ratingStars?: number; - description?: string; - coverHref?: string; - thumbHref?: string; - acquisitions: OpdsAcquisition[]; -} - -export interface OpdsFeed { - totalResults?: number; - nextHref?: string; - entries: OpdsEntry[]; -} - -export interface CalibreFormat { - name: string; - downloadUrl: string; -} diff --git a/src/lib/adapters/contract.ts b/src/lib/adapters/contract.ts deleted file mode 100644 index cd2c1e68..00000000 --- a/src/lib/adapters/contract.ts +++ /dev/null @@ -1,309 +0,0 @@ -/** - * Nexus Adapter Contract — the formal interface every adapter must satisfy. - * - * See docs/superpowers/specs/2026-04-14-adapter-contract-design.md for the - * full design rationale and method gating rules. - * - * This file is the single source of truth for the adapter interface. The - * existing ServiceAdapter (src/lib/adapters/base.ts) is being migrated onto - * this contract — both shapes co-exist during the transition; see - * base.ts for the compatibility re-exports. - */ - -import type { ServiceConfig, ServiceHealth, UserCredential } from './types'; - -/** Current contract version. Plugins must match this to be loaded. */ -export const ADAPTER_CONTRACT_VERSION = 1 as const; -export type AdapterContractVersion = typeof ADAPTER_CONTRACT_VERSION; - -/** - * Adapter tier — describes how user credentials are obtained (if at all). - * Orthogonal to whether the adapter needs admin credentials; see - * capabilities.adminAuth for that dimension. - * - * - `server`: no per-user credentials at all. Think Radarr, Sonarr. - * - `user-standalone`: each user has their own credential obtained via - * authenticateUser. Think Jellyfin, Invidious, Calibre-Web. - * - `user-derived`: credentials are derived from a parent adapter's - * credential. Think Overseerr (from Jellyfin), Streamystats (from Jellyfin). - */ -export type AdapterTier = 'server' | 'user-standalone' | 'user-derived'; - -export type MediaCapability = 'movie' | 'show' | 'book' | 'game' | 'music' | 'live' | 'video' | 'other'; - -// ───────────────────────────────────────────────────────────────────────────── -// Capability declarations -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Admin credential surface — install-wide material used for management - * operations (list users, create users, reset passwords) and for - * unauthenticated reads. Absent means the adapter has no concept of admin - * credentials. - */ -export interface AdapterAdminAuthCapabilities { - /** - * True when the adapter requires install-wide admin credentials to - * function at all. False means the adapter can work with just user - * credentials — no admin setup required. - */ - readonly required: boolean; - - /** - * Which fields on the services row the adapter consumes. The admin - * service-config form uses this to decide which inputs to render. - */ - readonly fields: ReadonlyArray; - - /** - * True if the adapter can cheaply verify its admin credential is still - * working. Enables server-level health tracking. - */ - readonly supportsHealthProbe: boolean; -} - -export type AdminAuthField = - | 'url' - | 'adminApiKey' - | 'adminUsername' - | 'adminPassword' - | 'adminUrlOverride'; - -/** - * User credential surface — per-user material used for personal interactions - * with the service. Absent means the adapter is server-level only. - */ -export interface AdapterUserAuthCapabilities { - /** - * Always true for adapters that declare userAuth. Present to make the - * type self-documenting. - */ - readonly userLinkable: true; - - /** Label for the username field in the Connect Account modal. */ - readonly usernameLabel?: string; - - /** - * True if the adapter supports user registration. Drives the - * "Create new account" toggle in the Connect Account modal. For - * Invidious-style services where /login handles both signin and register, - * this is TRUE even though there's no distinct createUser method. - * supportsAccountCreation below distinguishes the two. - */ - readonly supportsRegistration: boolean; - - /** - * True if the adapter has a distinct createUser method. Services with - * this capability: Jellyfin, RomM, Calibre-Web. False for Invidious - * (signin does double duty). - */ - readonly supportsAccountCreation: boolean; - - /** - * True if the adapter supports stored-password auto-refresh. Enables the - * "Save password for auto-reconnect" checkbox and the reconnect API. - */ - readonly supportsPasswordStorage: boolean; - - /** - * True if probeCredential can be called cheaply. Used by the - * accounts-page health probe. - */ - readonly supportsHealthProbe: boolean; - - /** - * For derived-tier adapters: the service types this adapter can auto-link - * from. Duplicated from capabilities.derivedFrom for type-level discovery. - */ - readonly derivedFrom?: readonly string[]; -} - -/** - * Top-level capabilities object. Each flag gates a method group — declaring - * the capability means the corresponding methods must be implemented. - */ -export interface AdapterCapabilities { - /** Media kinds this adapter surfaces. Drives UI/search routing. */ - readonly media?: readonly MediaCapability[]; - - /** Admin credential surface. Absent = no admin concept. */ - readonly adminAuth?: AdapterAdminAuthCapabilities; - - /** User credential surface. Absent = server-level only. */ - readonly userAuth?: AdapterUserAuthCapabilities; - - /** Library browsing enabled — requires getLibrary + getRecentlyAdded. */ - readonly library?: boolean; - - /** Unified search enabled — requires search method. */ - readonly search?: { - /** Lower is higher priority in unified search results. */ - priority: number; - }; - - /** Request management (Overseerr-style). */ - readonly requests?: boolean; - - /** Live playback session polling. */ - readonly sessions?: { - pollIntervalMs: number; - }; - - /** Recommendation/sync item export. */ - readonly sync?: boolean; - - /** Calendar/upcoming releases. */ - readonly calendar?: boolean; - - /** Enrichment-only adapter — no user-facing content. */ - readonly enrichmentOnly?: boolean; - - /** Declared parent adapter types for derived tier. */ - readonly derivedFrom?: readonly string[]; - /** True if this derived adapter cannot function without a linked parent. */ - readonly parentRequired?: boolean; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Method signature types -// ───────────────────────────────────────────────────────────────────────────── - -/** Return shape from authenticateUser / createUser / refreshCredential. */ -export interface UserCredentialResult { - accessToken: string; - externalUserId: string; - externalUsername: string; - /** Adapter-specific auth state, stored in user_service_credentials.extra_auth as JSON. */ - extraAuth?: Record; -} - -/** Result of a credential probe. */ -export type CredentialProbeResult = 'ok' | 'expired' | 'invalid'; - -/** - * Context passed to derived adapters during auto-linking. The shared registry - * constructs this from the parent credential that was just linked and passes - * it to the derived adapter's findAutoLinkMatch method. - */ -export interface LinkedParentContext { - /** The parent service's type (e.g. 'jellyfin', 'plex'). */ - readonly parentType: string; - /** The parent service's DB id. */ - readonly parentServiceId: string; - /** The parent credential's external user id — the primary match key. */ - readonly parentExternalUserId: string; - /** The parent credential's external username (fallback match hint). */ - readonly parentExternalUsername?: string; - /** The parent's access token, in case the derived adapter needs to call the parent API. */ - readonly parentAccessToken?: string; - /** The parent's ServiceConfig. */ - readonly parentConfig: ServiceConfig; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Adapter identity (base fields every adapter has) -// ───────────────────────────────────────────────────────────────────────────── - -export interface AdapterIdentity { - /** Unique adapter type key matching services.type. */ - readonly id: string; - /** Human-readable name shown in UI. */ - readonly displayName: string; - /** Default port for the setup wizard. */ - readonly defaultPort: number; - /** 2-char badge abbreviation (e.g. 'JF'). */ - readonly abbreviation: string; - /** Brand color for badges. */ - readonly color: string; - /** Icon name resolved by the ServiceIcon component. */ - readonly icon?: string; - /** Contract version this adapter was written against. */ - readonly contractVersion: AdapterContractVersion; - /** Adapter tier — determines which method groups are required. */ - readonly tier: AdapterTier; -} - -// ───────────────────────────────────────────────────────────────────────────── -// The full NexusAdapter interface -// ───────────────────────────────────────────────────────────────────────────── - -/** - * The full adapter contract. Plugins default-export an object satisfying this - * interface. The field requirements depend on `tier` and `capabilities` — - * see the design spec for the rules. - * - * Every adapter must implement `ping` regardless of tier. Everything else is - * scoped by capabilities. The conformance test suite validates that declared - * capabilities match implemented methods. - */ -export interface NexusAdapter extends AdapterIdentity { - readonly capabilities: AdapterCapabilities; - - /** Basic health check — required for every adapter. */ - ping(config: ServiceConfig): Promise; - - // ── Admin auth methods (required when capabilities.adminAuth.required) ── - /** Cheap probe of the admin credential. Required when supportsHealthProbe. */ - probeAdminCredential?(config: ServiceConfig): Promise; - - // ── User auth methods (required when capabilities.userAuth) ──────────── - /** Exchange username + password for a credential. */ - authenticateUser?( - config: ServiceConfig, - username: string, - password: string, - mode?: 'signin' | 'register' - ): Promise; - - /** Cheap probe of a user credential. Required when supportsHealthProbe. */ - probeCredential?( - config: ServiceConfig, - userCred: UserCredential - ): Promise; - - /** Refresh an expired credential using a stored password. Required when supportsPasswordStorage. */ - refreshCredential?( - config: ServiceConfig, - userCred: UserCredential, - storedPassword: string - ): Promise; - - /** Create a new account. Required when userAuth.supportsAccountCreation. */ - createUser?( - config: ServiceConfig, - username: string, - password: string - ): Promise; - - /** Headers for proxying authenticated images. */ - getImageHeaders?( - config: ServiceConfig, - userCred?: UserCredential - ): Promise>; - - // ── Derived-tier only ────────────────────────────────────────────────── - /** Given a parent credential, find a matching account on this derived service. */ - findAutoLinkMatch?( - config: ServiceConfig, - parent: LinkedParentContext - ): Promise; - - // ── Playback contract (Phase 2) ──────────────────────────────────────── - /** Negotiate playback for an item. Returns a PlaybackSession the client - * can hand to an engine (hls.js, dash.js, native

(open = false)} - onkeydown={(e) => e.key === 'Escape' && (open = false)} - role="button" - tabindex="-1" - aria-label="Close" - >
- - - -{/if} diff --git a/src/lib/components/BugReportModal.svelte b/src/lib/components/BugReportModal.svelte deleted file mode 100644 index defd950c..00000000 --- a/src/lib/components/BugReportModal.svelte +++ /dev/null @@ -1,180 +0,0 @@ - - - -{#if open} - - -
- -
-{/if} diff --git a/src/lib/components/CalendarRow.svelte b/src/lib/components/CalendarRow.svelte deleted file mode 100644 index be6e8709..00000000 --- a/src/lib/components/CalendarRow.svelte +++ /dev/null @@ -1,172 +0,0 @@ - - -
-
-

{title}

- {#if subtitle} - {subtitle} - {/if} - Full calendar → -
- - -
- - diff --git a/src/lib/components/CommandPalette.svelte b/src/lib/components/CommandPalette.svelte deleted file mode 100644 index 372ed704..00000000 --- a/src/lib/components/CommandPalette.svelte +++ /dev/null @@ -1,723 +0,0 @@ - - -{#if palette.open} -
e.key === 'Escape' && closePalette()} - role="button" - tabindex="-1" - aria-label="Close search" - > - -
-{/if} - - - -{#snippet posterThumb(item: UnifiedMedia)} -
- {#if item.poster} - - {:else} -
- -
- {/if} -
-{/snippet} - -{#snippet resultRow(item: UnifiedMedia, idx: number)} - - -
navigateToItem(item)} - onmouseenter={() => (activeIndex = idx)} - > - {@render posterThumb(item)} -
-
- {item.title} - {#if item.year}{item.year}{/if} -
-
- {#if !isScoped} - {typeSingular[item.type] ?? item.type} - {/if} - {#if isMusicTrack(item)} - {#if item.metadata?.artist} - {item.metadata.artist} - {/if} - {#if item.metadata?.album} - {item.metadata.album} - {/if} - {:else if item.type === 'video'} - {#if item.metadata?.author} - {item.metadata.author} - {/if} - {#if item.metadata?.viewCount} - {Number(item.metadata.viewCount).toLocaleString()} views - {/if} - {#if item.metadata?.publishedText} - {item.metadata.publishedText} - {/if} - {:else} - {#if item.rating} - - {item.rating.toFixed(1)} - - {/if} - {#if item.genres?.length} - - {/if} - {/if} -
-
- {#if isMusicTrack(item)} - - - - {:else} - {item.serviceType} - {/if} -
-{/snippet} diff --git a/src/lib/components/ContinueWatchingCard.svelte b/src/lib/components/ContinueWatchingCard.svelte deleted file mode 100644 index eeff9323..00000000 --- a/src/lib/components/ContinueWatchingCard.svelte +++ /dev/null @@ -1,90 +0,0 @@ - - - - -
- {#if thumbSrc && !imgError} - {item.title} (imgError = true)} - loading="lazy" - decoding="async" - fetchpriority="low" - /> - {:else} -
- {item.title} -
- {/if} - - - {#if item.progress != null && item.progress > 0 && item.progress < 1} -
-
-
- {/if} - - - {#if isPlayable} -
- -
- {/if} -
- - -
-

{item.title}

-

- {#if item.episodeInfo} - {item.episodeInfo} - {/if} - {#if item.episodeInfo && item.timeRemaining} - · - {/if} - {#if item.timeRemaining} - {item.timeRemaining} - {/if} -

-
-
diff --git a/src/lib/components/HeroCarousel.svelte b/src/lib/components/HeroCarousel.svelte deleted file mode 100644 index f6ba96a7..00000000 --- a/src/lib/components/HeroCarousel.svelte +++ /dev/null @@ -1,270 +0,0 @@ - - -{#if items.length > 0 && current} - - -{/if} - - diff --git a/src/lib/components/HeroSection.svelte b/src/lib/components/HeroSection.svelte deleted file mode 100644 index 968976d6..00000000 --- a/src/lib/components/HeroSection.svelte +++ /dev/null @@ -1,174 +0,0 @@ - - - - - -
- {#if backdrop} -
- {:else} -
- {/if} - - - -
-
- - {#if playing || trailerUrl} -
- {#if playing} - - {mode === 'browse' ? 'Preview' : 'Trailer'} - - {/if} - {#if playing} - - {/if} -
- {/if} - - {#if playing} -
-
-
- {/if} - -
- {@render children()} -
-
- - diff --git a/src/lib/components/MediaCard.svelte b/src/lib/components/MediaCard.svelte deleted file mode 100644 index 99c2e158..00000000 --- a/src/lib/components/MediaCard.svelte +++ /dev/null @@ -1,264 +0,0 @@ - - - - - -
- {#if item.poster && !imgError} - {item.title} (imgError = true)} - loading="lazy" - decoding="async" - fetchpriority="low" - /> - {:else} -
- - - {#if item.type === 'movie'} - - - - - {:else if item.type === 'show' || item.type === 'episode'} - - - - - {:else if item.type === 'book'} - - - - - - {:else if item.type === 'game'} - - - - - {:else if item.type === 'music' || item.type === 'album'} - - - - - - {:else if item.type === 'live'} - - - - - {:else} - - - - - {/if} - - {item.title} -
- {/if} - - - {#if item.progress != null && item.progress > 0 && item.progress < 1} -
-
-
- {/if} - - - {#if isPlayable} -
- -
- {/if} - - - {#if item.status && item.status !== 'available'} -
- {item.status} -
- {/if} - - -
- - {#if menuOpen} - -
- -
- {/if} -
-
- - -
- {#if item.type === 'episode' && item.metadata?.seriesName} -

- {item.metadata.seriesName} - {#if item.metadata.seasonNumber != null && item.metadata.episodeNumber != null} - S{String(item.metadata.seasonNumber).padStart(2, '0')}E{String(item.metadata.episodeNumber).padStart(2, '0')} - {/if} -

- {/if} -

{item.title}

-

- {#if item.year} - {item.year} - {/if} - {#if item.year && item.rating} - · - {/if} - {#if item.rating} - - {item.rating.toFixed(1)} - {/if} -

-
-
- - diff --git a/src/lib/components/MediaRow.svelte b/src/lib/components/MediaRow.svelte deleted file mode 100644 index cb191d8f..00000000 --- a/src/lib/components/MediaRow.svelte +++ /dev/null @@ -1,71 +0,0 @@ - - -
-
-
-

{row.title}

- {#if row.subtitle} -

{row.subtitle}

- {/if} -
-
- - -
-
- -
- {#each row.items as item (item.id)} -
- -
- {/each} -
-
diff --git a/src/lib/components/NavSidebar.svelte b/src/lib/components/NavSidebar.svelte deleted file mode 100644 index 89be9931..00000000 --- a/src/lib/components/NavSidebar.svelte +++ /dev/null @@ -1,350 +0,0 @@ - - - -{#if mobileOpen} - -
e.key === 'Escape' && closeMobile()} - role="button" - tabindex="-1" - aria-label="Close navigation" - >
-{/if} - - - diff --git a/src/lib/components/NotificationPanel.svelte b/src/lib/components/NotificationPanel.svelte deleted file mode 100644 index 73d6c750..00000000 --- a/src/lib/components/NotificationPanel.svelte +++ /dev/null @@ -1,284 +0,0 @@ - - -{#if open} - -
e.key === 'Escape' && closePanel()} - role="button" - tabindex="-1" - aria-label="Close notifications" - >
- - -
- -
-

Notifications

-
- {#if unreadCount > 0} - - {/if} - -
-
- - -
- {#if notifications.length === 0} -
- -

No notifications

-
- {:else} - {#each notifications as notif (notif.id)} - {@const iconInfo = iconForType(notif.type)} - {@const bg = bgForType(notif.type)} - {@const Tag = notif.href ? 'a' : 'div'} - - handleItemClick(notif)} - role={notif.href ? undefined : 'button'} - tabindex={notif.href ? undefined : 0} - > - -
-
- -
-
- - -
-
-

- {notif.title} -

-
- {timeAgo(notif.createdAt)} - {#if !notif.read} - - {/if} -
-
- {#if notif.message} -

{notif.message}

- {/if} - - {#if notif.type === 'session_invite'} -
- - -
- {/if} -
- - - {#if notif.href} -
- -
- {/if} -
- {/each} - {/if} -
- - - {#if notifications.length > 0} -
- -
- {/if} -
-{/if} - - diff --git a/src/lib/components/QualityBadge.svelte b/src/lib/components/QualityBadge.svelte deleted file mode 100644 index fa5db670..00000000 --- a/src/lib/components/QualityBadge.svelte +++ /dev/null @@ -1,138 +0,0 @@ - - -{#if badges.length > 0} - {#if mode === 'overlay'} -
- {#each badges as badge (badge.label)} - {badge.label} - {/each} -
- {:else} -
- {#each badges as badge (badge.label)} - {badge.label} - {/each} -
- {/if} -{/if} - - diff --git a/src/lib/components/SectionTabs.svelte b/src/lib/components/SectionTabs.svelte deleted file mode 100644 index 3986c6dd..00000000 --- a/src/lib/components/SectionTabs.svelte +++ /dev/null @@ -1,114 +0,0 @@ - - - -
- {#if icon} -
- {@render icon()} -
- {/if} -
-

- {title} -

-

{subtitle}

-
-
- - -
- {#each items as item, index (item.href)} - {@const active = isActive(item.href, index)} - - {item.label} - {#if item.badge != null} - {item.badge} - {/if} - - {/each} -
- - diff --git a/src/lib/components/ServiceBadge.svelte b/src/lib/components/ServiceBadge.svelte deleted file mode 100644 index 3695b153..00000000 --- a/src/lib/components/ServiceBadge.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - - - {name ?? type} - diff --git a/src/lib/components/Stickies.svelte b/src/lib/components/Stickies.svelte new file mode 100644 index 00000000..f253c035 --- /dev/null +++ b/src/lib/components/Stickies.svelte @@ -0,0 +1,318 @@ + + + +
+ {#each comments as c (c.id)} + {@const pos = stickyPos(c)} + {#if pos.visible} + {#if expandedIds.has(c.id)} +
+
+ {c.author || '?'} + {timeAgo(c.created_at)} + +
+
{c.body}
+ {#if c.anchor_snippet}
"{c.anchor_snippet}"
{/if} + +
+ {:else} + + {/if} + {/if} + {/each} +
+ + + +{#if mode && !composer} +
Click anywhere to add a note · Esc to exit
+{/if} + + +{#if composer} + {@const pos = clampComposerPos()} + +
+
+ "{composer.anchor_snippet || 'no text'}" + +
+ + +
+ + +
+
+{/if} + + diff --git a/src/lib/components/ToastContainer.svelte b/src/lib/components/ToastContainer.svelte deleted file mode 100644 index 125bce99..00000000 --- a/src/lib/components/ToastContainer.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - -
- {#each toasts as toast (toast.id)} - {@const Icon = iconMap[toast.type]} -
- - - - - {toast.message} - - -
- {/each} -
- - diff --git a/src/lib/components/TrailerPlayer.svelte b/src/lib/components/TrailerPlayer.svelte deleted file mode 100644 index 8c8e31c2..00000000 --- a/src/lib/components/TrailerPlayer.svelte +++ /dev/null @@ -1,147 +0,0 @@ - - - -
- {#if src && !failed} - - {/if} -
- - diff --git a/src/lib/components/WatchlistButton.svelte b/src/lib/components/WatchlistButton.svelte deleted file mode 100644 index 2946faa5..00000000 --- a/src/lib/components/WatchlistButton.svelte +++ /dev/null @@ -1,71 +0,0 @@ - - - diff --git a/src/lib/components/account-linking/AccountLinkModal.svelte b/src/lib/components/account-linking/AccountLinkModal.svelte deleted file mode 100644 index f0f0d473..00000000 --- a/src/lib/components/account-linking/AccountLinkModal.svelte +++ /dev/null @@ -1,241 +0,0 @@ - - - -
{ - if (e.target === e.currentTarget) onCancel(); - }} - role="dialog" - aria-modal="true" - aria-labelledby="account-link-modal-title" -> -
- -

- {#if isRegisterMode} - Creates a new account on - {service.url}. You'll own this account — Nexus just uses - it to fetch your personal data. - {:else} - Connecting to {service.url} - {/if} -

- -
-
- - -
- -
- -
- - -
-
- - {#if isRegisterMode} -
- - - {#if confirmPassword.length > 0 && password !== confirmPassword} -

Passwords don't match.

- {/if} -
- {/if} - - {#if supportsPasswordStorage} - - {/if} -
- - {#if formState.kind === 'error'} -
- {formState.message} -
- {/if} - - {#if supportsRegistration} -
- {#if isRegisterMode} - Already have an account? - - {:else} - First time? - - {/if} -
- {/if} - -
- - -
-
-
diff --git a/src/lib/components/account-linking/SignInCard.svelte b/src/lib/components/account-linking/SignInCard.svelte deleted file mode 100644 index cbf6d4fa..00000000 --- a/src/lib/components/account-linking/SignInCard.svelte +++ /dev/null @@ -1,110 +0,0 @@ - - -{#if variant === 'hero'} -
-
- {service.abbreviation} -
-

Connect your {service.name} account

- {#if featureSentence} -

- See your {featureSentence} from {service.url}. -

- {:else} -

- Connect to {service.url}. -

- {/if} - - {#if service.capabilities.userAuth?.supportsRegistration} -

- Don't have an account? - -

- {/if} -
-{:else} -
-
-
- {service.abbreviation} -
-
-
Connect your {service.name} account
- {#if featureSentence} -
- See {featureSentence} -
- {:else} -
{service.url}
- {/if} -
-
- -
-{/if} - -{#if modalOpen} - (modalOpen = false)} - /> -{/if} diff --git a/src/lib/components/account-linking/StaleCredentialBanner.svelte b/src/lib/components/account-linking/StaleCredentialBanner.svelte deleted file mode 100644 index fd8afa61..00000000 --- a/src/lib/components/account-linking/StaleCredentialBanner.svelte +++ /dev/null @@ -1,113 +0,0 @@ - - -
-
-
- ⚠ -
-
-
- Your {service.name} session expired -
-
- {context ?? service.url} - {#if bannerState.kind === 'error'}— {bannerState.message}{/if} -
-
-
- -
- -{#if modalOpen} - (modalOpen = false)} - /> -{/if} diff --git a/src/lib/components/account-linking/errorCopy.ts b/src/lib/components/account-linking/errorCopy.ts deleted file mode 100644 index c6bbec60..00000000 --- a/src/lib/components/account-linking/errorCopy.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Maps AdapterAuthError.kind values to user-facing messages. Shared by the - * AccountLinkModal, SignInCard, and StaleCredentialBanner components so copy - * stays consistent across every account-linking flow. - */ - -import type { AdapterAuthErrorKind } from '$lib/adapters/errors'; - -/** - * Return a human-readable message for a given AdapterAuthError kind. Takes - * the service name so copy can reference the specific service (e.g. - * "Can't reach Jellyfin"). - */ -export function errorCopyForKind( - kind: AdapterAuthErrorKind | string, - serviceName: string, - retryAfterMs?: number -): string { - switch (kind) { - case 'invalid': - return `Username or password doesn't match.`; - case 'expired': - return `Your ${serviceName} session expired.`; - case 'rate-limited': { - const minutes = retryAfterMs ? Math.ceil(retryAfterMs / 60_000) : 0; - return minutes > 0 - ? `Too many attempts. Try again in ${minutes} minute${minutes === 1 ? '' : 's'}.` - : `Too many attempts. Wait a bit and try again.`; - } - case 'registration-disabled': - return `${serviceName} doesn't allow new accounts. Ask your admin, or choose another instance.`; - case 'unreachable': - return `Can't reach ${serviceName}. Check that it's running and the URL is correct.`; - case 'permission-denied': - return `You signed in to ${serviceName}, but your account doesn't have permission for this action.`; - case 'parent-stale': - return `Can't connect ${serviceName} because its parent service needs to reconnect first.`; - case 'no-stored-password': - return `No saved password for ${serviceName}. Sign in manually to reconnect.`; - case 'not-linked': - return `You're not connected to ${serviceName}.`; - case 'unsupported': - return `${serviceName} doesn't support automatic reconnect. Sign in manually.`; - default: - return `Something went wrong with ${serviceName}.`; - } -} diff --git a/src/lib/components/account-linking/types.ts b/src/lib/components/account-linking/types.ts deleted file mode 100644 index e2aa0c6f..00000000 --- a/src/lib/components/account-linking/types.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Shared types for the account-linking UI components. - * - * AccountServiceSummary is the normalized shape that every component consumes. - * It's computed server-side from services + user_service_credentials + adapter - * capabilities. Never contains raw access tokens or stored passwords — only - * metadata and state. See docs/superpowers/specs/2026-04-14-settings-ux- - * rework-design.md §"Shared components". - */ - -import type { AdapterCapabilities } from '$lib/adapters/contract'; - -export interface AccountServiceSummary { - /** Service ID (services.id — unique per registered instance). */ - id: string; - /** Display name, e.g. "Jellyfin (home server)". */ - name: string; - /** Adapter type key, e.g. 'jellyfin'. */ - type: string; - /** Instance URL the user is connecting to. */ - url: string; - /** Brand color for badges. */ - color: string; - /** 2-char abbreviation. */ - abbreviation: string; - /** Icon name resolved by ServiceIcon. */ - icon?: string; - /** Full adapter capabilities object — drives modal/card behavior. */ - capabilities: AdapterCapabilities; - /** True if the current user has a credential for this service. */ - isLinked: boolean; - /** ISO timestamp when the credential went stale, null if healthy. */ - staleSince: string | null; - /** External username on the remote service (if linked). */ - externalUsername: string | null; - /** True if the credential was created by Nexus via createUser. */ - nexusManaged: boolean; - /** True if the credential was auto-linked from a parent service. */ - autoLinked: boolean; - /** True if the credential has a stored password for auto-reconnect. */ - hasStoredPassword: boolean; - /** Parent service summary if this is a derived credential. */ - parentServiceName: string | null; -} diff --git a/src/lib/components/admin/AdminAnalytics.svelte b/src/lib/components/admin/AdminAnalytics.svelte deleted file mode 100644 index 3b9c7ede..00000000 --- a/src/lib/components/admin/AdminAnalytics.svelte +++ /dev/null @@ -1,453 +0,0 @@ - - -
- -
- {#each periods as p (p.key)} - - {/each} -
- - {#if loading} - -
- {#each Array(5) as _, i (i)} -
-
-
-
- {/each} -
-
-
-
-
-
-
- {:else} - -
- -
-
- {stats ? formatMs(stats.totalPlayTimeMs) : '0m'} -
-
Total Play Time
-
- - -
-
- {stats?.totalSessions?.toLocaleString() ?? 0} -
-
Sessions
-
- - -
-
- {stats?.totalItems?.toLocaleString() ?? 0} -
-
Unique Items
-
- - -
-
- {stats?.activeUsers ?? 0} -
-
Active Users
-
- - -
-
- {formatMs(avgSessionMs)} -
-
Avg Session
-
-
- - -
-

- Play Time Timeline -

- {#if timeline.length > 0} -
-
- {#each timeline as day (day.date)} - {@const pct = (day.playTimeMs / timelineMax) * 100} -
- {/each} -
- -
- {timeline[0]?.date ?? ''} - {#if timeline.length > 2} - {timeline[Math.floor(timeline.length / 2)]?.date ?? ''} - {/if} - {timeline[timeline.length - 1]?.date ?? ''} -
-
- {:else} -
-

No timeline data available

-
- {/if} -
- - -
-

- Activity Heatmap -

- {#if stats?.hourlyDistribution && stats?.weekdayDistribution} - {@const maxVal = Math.max(...stats.hourlyDistribution, ...stats.weekdayDistribution, 1)} -
- -
-

By Day of Week

-
- {#each ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as dayLabel, i (dayLabel)} - {@const intensity = stats.weekdayDistribution[i] - ? Math.max(0.1, stats.weekdayDistribution[i] / maxVal) - : 0.05} -
-
- {dayLabel} -
- {/each} -
-
- -
-

By Hour

-
- {#each Array(24) as _, h (h)} - {@const intensity = stats.hourlyDistribution[h] - ? Math.max(0.1, stats.hourlyDistribution[h] / maxVal) - : 0.05} -
- {/each} -
-
- 12am - 6am - 12pm - 6pm - 11pm -
-
-
- {:else} -
-

Heatmap requires per-user stats data

-
- {/if} -
- - -
-

- Top Genres -

- {#if stats?.topGenres && stats.topGenres.length > 0} - {@const maxGenre = Math.max(...stats.topGenres.map((g: any) => g.playTimeMs ?? g.count ?? 1))} -
- {#each stats.topGenres as genre, i (genre.name ?? i)} - {@const val = genre.playTimeMs ?? genre.count ?? 0} - {@const pct = (val / maxGenre) * 100} -
- {i + 1} -
-
- {genre.name} - - {genre.playTimeMs ? formatMs(genre.playTimeMs) : genre.count} - -
-
-
-
-
-
- {/each} -
- {:else} -
-

No genre data available

-
- {/if} -
- - -
-

- Devices & Clients -

- {#if stats?.topDevices && stats.topDevices.length > 0} - {@const maxDevice = Math.max(...stats.topDevices.map((d: any) => d.playTimeMs ?? d.count ?? 1))} -
- -
-

Devices

- {#each stats.topDevices as device (device.name)} - {@const val = device.playTimeMs ?? device.count ?? 0} - {@const pct = (val / maxDevice) * 100} -
-
- - - - - - {device.name} - - - {device.playTimeMs ? formatMs(device.playTimeMs) : device.count} - -
-
-
-
-
- {/each} -
- - -
-

Usage Share

- {#each stats.topDevices as device (device.name)} - {@const val = device.sessions ?? device.count ?? 0} - {@const total = stats.topDevices.reduce((s: number, d: any) => s + (d.sessions ?? d.count ?? 0), 0)} - {@const pct = total > 0 ? (val / total) * 100 : 0} -
- {device.name} - - {Math.round(pct)}% - -
- {/each} -
-
- {:else} -
-

No device data available

-
- {/if} -
- - -
-

- User Activity -

- {#if sortedUsers.length > 0} -
- - - - - - - - - - - {#each sortedUsers as user (user.userId)} - - - - - - - {/each} - -
UserPlay Time
-
-
- {(user.displayName ?? user.username ?? '?').charAt(0)} -
- {user.displayName ?? user.username ?? 'Unknown'} -
-
{formatMs(user.totalPlayTimeMs ?? 0)}
-
- {:else} -
-

No user activity data

-
- {/if} -
- {/if} -
diff --git a/src/lib/components/admin/AdminContent.svelte b/src/lib/components/admin/AdminContent.svelte deleted file mode 100644 index 1f69abcb..00000000 --- a/src/lib/components/admin/AdminContent.svelte +++ /dev/null @@ -1,312 +0,0 @@ - - -
- {#if loading} - -
- {#each Array(5) as _, i (i)} -
-
-
-
-
- {/each} -
-
-
-
-
- {#each Array(10) as _, i (i)} -
-
-
-
-
-
-
- {/each} -
- {:else if contentData} - -
-

- Library Overview -

-
- {#each contentData.byType as item (item.type)} - {@const color = typeColor(item.type)} -
- -
- {#if item.type.toLowerCase() === 'movie'} - - - - - {:else if item.type.toLowerCase() === 'show'} - - - - - {:else if item.type.toLowerCase() === 'book'} - - - - - {:else if item.type.toLowerCase() === 'game'} - - - - - - - {:else if item.type.toLowerCase() === 'music'} - - - - - - {:else} - - - - {/if} -
- -
- {item.count.toLocaleString()} -
-
- {item.type}{item.count !== 1 ? 's' : ''} -
- {#if playTimeForType(item.type) > 0} -
- {formatMs(playTimeForType(item.type))} played -
- {/if} -
- {/each} -
-
- - - {#if contentData.byService && contentData.byService.length > 0} -
-

- By Service -

-
- {#each contentData.byService as svc (svc.serviceId)} - {@const pct = (svc.count / maxServiceCount) * 100} -
-
-
- {svc.serviceName} - - {svc.serviceType} - -
-
-
-
-
- - {svc.count.toLocaleString()} - -
- {/each} -
-
- {/if} - - - {#if contentData.gaps && (contentData.gaps.missingPoster > 0 || contentData.gaps.missingDescription > 0)} -
-
-
- - - - - -
-
-

Content Gaps Detected

-
- {#if contentData.gaps.missingPoster > 0} - - {contentData.gaps.missingPoster} missing poster{contentData.gaps.missingPoster !== 1 ? 's' : ''} - - {/if} - {#if contentData.gaps.missingDescription > 0} - - {contentData.gaps.missingDescription} missing description{contentData.gaps.missingDescription !== 1 ? 's' : ''} - - {/if} -
-
-
-
- {/if} - - - {#if contentData.recent && contentData.recent.length > 0} -
-

- Recent Additions -

-
- {#each contentData.recent.slice(0, 20) as item (item.id)} - {@const color = typeColor(item.type)} -
- - {#if item.poster} -
- {item.title} -
- {:else} -
- - - - - -
- {/if} - - -
-

{item.title}

-
- - {item.type} - - {#if item.cachedAt} - - {timeAgo(item.cachedAt)} - - {/if} -
-
-
- {/each} -
-
- {/if} - {:else} - -
- - - - -

Failed to load content data

-
- {/if} -
diff --git a/src/lib/components/admin/AdminOverview.svelte b/src/lib/components/admin/AdminOverview.svelte deleted file mode 100644 index 3d2219a7..00000000 --- a/src/lib/components/admin/AdminOverview.svelte +++ /dev/null @@ -1,460 +0,0 @@ - - - - -
- -
-
{playingCount}
-
Live now
- {#if pausedCount > 0} -
- {playingCount} playing · {pausedCount} paused -
- {/if} - {#if playingCount > 0} -
- {/if} -
- - -
-
{data.onlineUsers}
-
Online Users
-
- - -
-
{pendingCount}
-
Pending Requests
-
- - -
-
- {onlineCount}/{totalServices} -
-
Services Online
-
- - -
-
{formatMs(data.playTimeToday)}
-
Play Time Today
-
- - -
-
{data.totalUsers}
-
Total Users
-
-
- - -
-
-

Live Now

- {#if playingCount > 0} - - {/if} -
- - {#if data.sessions.length === 0} -
- - - - - -

No active streams

-
- {:else} -
- {#each data.sessions as session (session.Id)} - {@const img = itemBackdrop(session)} - {@const lowResImg = lowResImageUrl(img)} - {@const pct = progress(session)} -
- {#if img} -
- {#if lowResImg && !loadedBackdrops[session.Id]} - - {/if} - (loadedBackdrops = { ...loadedBackdrops, [session.Id]: true })} - /> -
-
- {/if} - -
- {#if img} - - {/if} - -
-
-
-

{itemTitle(session)}

- {#if session.NowPlayingItem?.Type === 'Episode' && session.NowPlayingItem?.Name} -

{session.NowPlayingItem.Name}

- {/if} -
- - {methodLabel(session.PlayState?.PlayMethod)} - -
- -
- - - {session.UserName ?? 'Unknown'} - - · - {session.Client ?? 'Unknown'} - {#if session.DeviceName} - · - {session.DeviceName} - {/if} - {#if session.PlayState?.IsPaused} - Paused - {/if} -
- - {#if pct > 0} -
-
-
-
- {Math.round(pct * 100)}% - {#if session.NowPlayingItem?.RunTimeTicks} - {formatRuntime(session.NowPlayingItem.RunTimeTicks)} - {/if} -
- {/if} -
-
-
- {/each} -
- {/if} -
- - -{#if data.recentEvents && data.recentEvents.length > 0} -
-

Recent Activity

- -
- {#each data.recentEvents as event, i (i)} - {@const icon = eventIcon(event.eventType)} -
- - {#if icon.type === 'play'} - - - - {:else} - - - - {/if} - -
-

- {event.userName ?? 'Unknown'} - {eventLabel(event.eventType)} - {event.mediaTitle} -

-
- {#if event.mediaType} - {event.mediaType} - · - {/if} - {#if event.playDurationMs && event.eventType !== 'play_start'} - {formatMs(event.playDurationMs)} - · - {/if} - {timeAgo(event.timestamp)} -
-
-
- {/each} -
-
-{/if} - - - - - - - - -
- -
-
-

Request Queue

- View all → -
- - {#if data.requests.length === 0} -
-

No recent requests

-
- {:else} -
- {#each data.requests.slice(0, 5) as req (req.id)} - {@const reqLowRes = lowResImageUrl(req.poster)} -
- {#if req.poster} -
- {#if reqLowRes && !loadedRequestPosters[req.id]} - - {/if} - {req.title} (loadedRequestPosters = { ...loadedRequestPosters, [req.id]: true })} - /> -
- {:else} -
- - - -
- {/if} - -
-

{req.title}

-
- {req.requestedByName} - · - {timeAgo(req.requestedAt)} -
-
- - - {req.status} - -
- {/each} -
- {/if} -
-
diff --git a/src/lib/components/admin/AdminServices.svelte b/src/lib/components/admin/AdminServices.svelte deleted file mode 100644 index 88d5d460..00000000 --- a/src/lib/components/admin/AdminServices.svelte +++ /dev/null @@ -1,1109 +0,0 @@ - - - -
-
-

- Configured Services - {#if data.services?.length > 0} - · {data.services.length} - {/if} -

- {#if !showAddForm} - - {/if} -
- - - {#if showAddForm} -
-
-

Add New Service

- -
- -
- - - - - - - - - - - - - - - - - -
- - - {#if testResult} -
- {#if testResult.loading} - Testing connection... - {:else if testResult.ok} - Connected successfully{testResult.latency != null ? ` (${testResult.latency}ms)` : ''} - {:else} - {testResult.error ?? 'Connection failed'} - {/if} -
- {/if} - - -
- - -
-
- {/if} - - - {#if !data.services || data.services.length === 0} -
- - - - -

No services configured yet

- {#if !showAddForm} - - {/if} -
- {:else} -
- {#each data.services as svc (svc.id)} - {@const health = getHealthForService(svc.id)} - {@const isOnline = health?.online ?? false} - {@const color = typeColor(svc.type)} - {@const abbrev = typeAbbreviation(svc.type)} - - {#if editingId === svc.id} - -
-
-
-
- {abbrev} -
- Edit {svc.type} -
- -
- -
- - - - - - -
- - {#if testResult} -
- {#if testResult.loading} - Testing connection... - {:else if testResult.ok} - Connected{testResult.latency != null ? ` (${testResult.latency}ms)` : ''} - {:else} - {testResult.error ?? 'Failed'} - {/if} -
- {/if} - -
- - -
-
- {:else} - -
-
- -
- {abbrev} -
- -
-
-

{svc.name}

- - {#if health} -
-
- {/if} -
-

{svc.type}

-

{svc.url}

-
-
- -
- - - -
- {#if data.available?.find((a: any) => a.id === svc.type)?.supportsGetUsers} - - {/if} - - - {#if deleteConfirmId === svc.id} - - - {:else} - - {/if} -
-
-
- {/if} - {/each} -
- {/if} -
- - -{#if autoLinkServiceId} - {@const svcName = data.services?.find((s: any) => s.id === autoLinkServiceId)?.name ?? 'Service'} -
-
-

- User Discovery — {svcName} -

- -
- -
- {#if autoLinkLoading && !autoLinkPreview} -

Discovering users...

- {:else if autoLinkPreview} - {#if autoLinkPreview.length === 0} -

No users found on this service.

- {:else} -

Found {autoLinkPreview.length} users. Matching accounts will be auto-linked to Nexus users.

- -
- {#each autoLinkPreview as user (user.externalId)} -
-
-
- {user.externalUsername} - {#if user.isAdmin} - Admin - {/if} -
- {#if user.status === 'already-linked'} - Linked to @{user.nexusUsername} - {:else if user.status === 'match'} - → @{user.nexusUsername} - {:else} - - - {/if} -
-
- {/each} -
- - {@const matchCount = autoLinkPreview.filter((u: any) => u.status === 'match').length} - {@const manualCount = Object.values(manualMappings).filter(Boolean).length} - {@const totalToLink = matchCount + manualCount} - {#if totalToLink > 0} - - {:else} -

All users are already linked. Use the dropdowns above to manually assign unmatched accounts.

- {/if} - {/if} - {/if} - - {#if autoLinkResults} -
-

Auto-Link Complete

-
- {#each autoLinkResults as r (r.externalId)} -
- {#if r.status === 'linked'} - - {:else if r.status === 'already-linked'} - - {:else} - - {/if} - {r.externalUsername} - {r.status} -
- {/each} -
-
- {/if} -
-
-{/if} - - -
-

Service Health

- - {#if !data.health || data.health.length === 0} -
- - - - -

No services configured

-
- {:else} -
- {#each data.health as svc (svc.serviceId)} - {@const ping = pingResults[svc.serviceId]} - {@const isOnline = ping ? ping.ok : svc.online} -
-
- -
-
- -
-

{svc.name}

-

{svc.type}

- {#if svc.url} -

{svc.url}

- {/if} -
-
- -
- - {#if ping?.loading} - Testing... - {:else if ping} - {#if ping.ok && ping.latency != null} - {ping.latency}ms - {:else if !ping.ok} - {ping.error ?? 'Offline'} - {/if} - {:else if isOnline && svc.latency != null} - {svc.latency}ms - {:else if !isOnline && svc.error} - {svc.error} - {:else} - - {/if} - - -
-
- {/each} -
- {/if} -
- - -
-
-

- Download Queue - {#if (data.queue ?? []).length > 0} - · {(data.queue ?? []).length} - {/if} -

- - - - - - Refresh - -
- - {#if (data.queue ?? []).length === 0} -
-

Nothing downloading

-
- {:else} -
- {#each data.queue ?? [] as item (item.id)} -
- {#if item.poster} - {item.title} - {:else} -
- - - -
- {/if} - -
-

{item.title}

-
- {item.serviceType} -
-
- - - {item.status ?? 'unknown'} - -
- {/each} -
- {/if} -
- - -
-
-

Request Queue

- View all → -
- - {#if (data.requests ?? []).length === 0} -
-

No recent requests

-
- {:else} -
- {#each data.requests ?? [] as req (req.id)} -
- {#if req.poster} - {req.title} - {:else} -
- - - -
- {/if} - -
-

{req.title}

-
- {req.requestedByName} - · - {timeAgo(req.requestedAt)} -
-
- - - {req.status} - -
- {/each} -
- {/if} -
- - -{#if proxy} -
-
-

Stream Proxy

-
- up {formatUptime(proxy.uptime)} -
- - -
-
-
{proxy.totalRequests.toLocaleString()}
-
Total Requests
-
-
-
{proxy.activeConnections}
-
Active Connections
-
-
-
{formatBytes(proxy.bytesServed)}
-
Bytes Served
-
-
-
{proxyHitRate}%
-
Cache Hit Rate
-
-
- -
- - {#if proxy.videos && proxy.videos.length > 0} -
-
-

Videos Served

-
-
- {#each proxy.videos.slice(0, 8) as v (v.videoId)} -
-
-

{v.videoId}

-
- {v.requests} req - · - {formatBytes(v.bytes)} -
-
-
- {#each Object.entries(v.itags || {}) as [itag, count]} - - {itag}x{count} - - {/each} -
-
- {/each} -
-
- {/if} - - - {#if proxy.recent && proxy.recent.length > 0} -
-
-

Recent Requests

-
-
- {#each proxy.recent.slice(0, 10) as r, i (i)} -
-
-
-
- {r.videoId} - itag {r.itag} -
-
-
- {#if r.cached} - HIT - {:else} - MISS - {/if} - {r.durationMs}ms - {formatBytes(r.bytes)} -
-
- {/each} -
-
- {/if} -
- - - {#if proxy.errors > 0} -
- - {proxy.errors} errors since startup -
- {/if} -
-{/if} - - -{#if data.prowlarr} -
-

Prowlarr Indexers

- - {#if data.prowlarr.stats} -
-
-
{data.prowlarr.stats.indexerCount ?? 0}
-
Total Indexers
-
-
-
{data.prowlarr.stats.grabCount ?? 0}
-
Total Grabs
-
-
-
{data.prowlarr.stats.queryCount ?? 0}
-
Total Queries
-
-
-
{data.prowlarr.stats.failCount ?? 0}
-
Failures
-
-
- {/if} - - {#if data.prowlarr.indexers && data.prowlarr.indexers.length > 0} -
- {#each data.prowlarr.indexers as indexer (indexer.id)} -
-
-
- -
-

{indexer.name}

-
- {#if indexer.protocol} - {indexer.protocol} - {/if} - {#if indexer.privacy} - · - {indexer.privacy} - {/if} -
-
- - - {indexer.enable ? 'Enabled' : 'Disabled'} - -
- {/each} -
- {:else} -
-

No indexers configured

-
- {/if} -
-{/if} diff --git a/src/lib/components/admin/AdminSystem.svelte b/src/lib/components/admin/AdminSystem.svelte deleted file mode 100644 index f66f555f..00000000 --- a/src/lib/components/admin/AdminSystem.svelte +++ /dev/null @@ -1,302 +0,0 @@ - - -{#if loading} - -
- {#each Array(4) as _} -
-
-
-
-
-
-
- {/each} -
-{:else if systemData} -
- - -
-

Cache Management

-
-
- - - - - - - - -
-
-
- - -
-

Database

-
- -
-
-

File

-

{systemData.db?.path ?? 'Unknown'}

-
-
-

Size

-

{formatBytes(systemData.db?.sizeBytes ?? 0)}

-
-
- - - {#if systemData.db?.rowCounts} -
- {#each Object.entries(systemData.db.rowCounts) as [table, count], i (table)} -
- {tableNames[table] ?? formatTableName(table)} - {typeof count === 'number' ? count.toLocaleString() : count} -
- {/each} -
- {/if} -
-
- - -
-

Stats Engine

-
-

Rebuild pre-computed statistics for all users. This may take a while for large databases.

- - - {#if rebuildResult} -
- - - Rebuilt {rebuildResult.rebuilt ?? rebuildResult.count ?? '?'}/{rebuildResult.total ?? '?'} users - -
- {/if} - - {#if !rebuildingStats && !rebuildResult} -
- - Large databases may take several minutes -
- {/if} -
-
- - -
-

WebSocket Status

-
-
-
-
{systemData.ws?.connectedUsers ?? 0}
-
-

Connected Users

-
- {#if (systemData.ws?.connectedUsers ?? 0) > 0} -
- {/if} -

Live connections

-
-
-
-
- - {#if systemData.ws?.onlineUserIds?.length > 0} -
- {#each systemData.ws.onlineUserIds as userId (userId)} - - {userId} - - {/each} -
- {:else} -
-

No users connected

-
- {/if} -
-
- - -
-

App Settings

-
- {#if systemData.appSettings && Object.keys(systemData.appSettings).length > 0} -
- {#each Object.entries(systemData.appSettings) as [key, value], i (key)} -
- {key} - {value} -
- {/each} -
- {:else} -
-

No settings configured

-
- {/if} -
-
-
-{/if} diff --git a/src/lib/components/admin/AdminUsers.svelte b/src/lib/components/admin/AdminUsers.svelte deleted file mode 100644 index 3291d364..00000000 --- a/src/lib/components/admin/AdminUsers.svelte +++ /dev/null @@ -1,900 +0,0 @@ - - -{#if loading} - -
- {#each Array(4) as _} -
-
-
-
-
-
-
- {/each} -
-{:else} -
- - -
-

Registration Settings

-
-
- -
-
-

Open Registration

-

Allow new users to register without an invite

-
- -
- - - {#if regEnabled} -
-
-

Require Approval

-

Admin must approve new registrations before access is granted

-
- -
- {/if} -
-
-
- - - {#if pendingUsers.length > 0} -
-
-

Pending Approvals

- {pendingUsers.length} -
-
- {#each pendingUsers as user (user.id)} -
- -
- {initials(user.displayName || user.username)} -
-
-

{user.displayName || user.username}

-
- @{user.username} - · - {formatDate(user.createdAt)} -
-
- Pending -
- - -
-
- {/each} -
-
- {/if} - - -
-
-

- Users - · {users.length} -

- -
- - - {#if showCreateUser} -
-

- Creates a local account. The user will be required to set a new password on first login. -

-
-
- - -
-
- - -
-
- - -
- -
- {#if createError} -

{createError}

- {/if} - {#if createSuccess} -

- User @{createSuccess} created. They will be prompted to set a new password on first login. -

- {/if} -
- {/if} - - {#if users.length === 0} -
-

No users found

-
- {:else} -
- {#each users as user (user.id)} - {@const isOnline = onlineUserIds.has(user.id)} -
-
- -
-
- {initials(user.displayName || user.username)} -
- {#if isOnline} -
- {/if} -
- -
-
-

{user.displayName || user.username}

- {#if user.isAdmin} - Admin - {/if} - {#if user.authProvider && user.authProvider !== 'local'} - {user.authProvider} - {/if} - {#if user.forcePasswordReset} - Reset Required - {/if} -
-

@{user.username}

-
- - - {#if !user.isAdmin} -
- - - - - - {#if deleteUserConfirm === user.id} - - - {:else} - - {/if} -
- {/if} -
- - - {#if resetPasswordUserId === user.id} -
- - - -
- {#if resetPasswordError} -

{resetPasswordError}

- {/if} - {/if} -
- {/each} -
- {/if} -
- - -
-

Invite Links

- - -
-
-
- - -
-
- - -
- -
- - - {#if newInviteCode} - {@const inviteUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/invite?code=${newInviteCode}`} -
- - {inviteUrl} - -
- {/if} -
- - - {#if invites.length > 0} -
- {#each invites as invite (invite.code)} - {@const inviteLink = `${typeof window !== 'undefined' ? window.location.origin : ''}/invite?code=${invite.code}`} - {@const isExpired = invite.expiresAt && new Date(invite.expiresAt) < new Date()} -
-
- {invite.code} -
- {invite.uses ?? 0}/{invite.maxUses ?? '?'} uses - {#if invite.expiresAt} - · - {#if isExpired} - Expired - {:else} - Expires {formatDate(invite.expiresAt)} - {/if} - {:else} - · - No expiry - {/if} -
-
- - {#if deleteInviteConfirm === invite.code} - - - {:else} - - {/if} -
- {/each} -
- {:else} -
-

No active invites

-
- {/if} -
- - -
-

Jellyfin Migration

-
-

Import users from your Jellyfin server into Nexus. Preview first to see which users will be imported.

- -
- - {#if migratePreview && migratePreview.length > 0} - - {/if} -
- - - {#if migratePreview} - {#if migratePreview.length === 0} -
- No new Jellyfin users to import -
- {:else} -
- - -
-
- {#each migratePreview as jfUser (jfUser.externalId || jfUser.username)} -
- { - if (migrateSelected.has(jfUser.externalId)) { - migrateSelected.delete(jfUser.externalId); - migrateSelected = new Set(migrateSelected); - } else { - migrateSelected.add(jfUser.externalId); - migrateSelected = new Set(migrateSelected); - } - }} - class="h-3.5 w-3.5 rounded accent-[var(--color-accent)]" - /> -
- {initials(jfUser.username)} -
- {jfUser.username} - {#if jfUser.isAdmin} - Admin - {/if} - {jfUser.serviceName} -
- {/each} -
- {/if} - {/if} - - - {#if migrateResult} -
-

Imported {migrateResult.imported ?? 0} users

- {#if migrateResult.results} -
- {#each migrateResult.results as r (r.username)} -
- {#if r.status === 'created' || r.status === 'success'} - - {:else} - - {/if} - {r.username} - {r.status} -
- {/each} -
- {/if} -
-
- - Reset & link Jellyfin accounts for imported users -
- {#if autoLinkResult} -
-

Auto-Link Results

-
- {#each autoLinkResult as r} -
- {#if r.status === 'linked'} - - {:else if r.status === 'already-linked'} - - {:else} - - {/if} - {r.externalUsername} - → {r.nexusUsername ?? '—'} - {r.status} -
- {/each} -
-
- {/if} - {/if} -
-
-
-{/if} - - diff --git a/src/lib/components/admin/DownloadQueue.svelte b/src/lib/components/admin/DownloadQueue.svelte deleted file mode 100644 index f8023bf7..00000000 --- a/src/lib/components/admin/DownloadQueue.svelte +++ /dev/null @@ -1,498 +0,0 @@ - - -
-
-

- Downloads - {#if items.length > 0} - {items.length} - {/if} -

-
- {#each filters as f (f.value)} - - {/each} -
-
- - {#if loading && items.length === 0} -
-

Loading downloads...

-
- {:else if items.length === 0} -
- - - -

No active downloads

-
- {:else} -
- - - - - - - - - - - - - - {#each items as item (item.metadata?.queueId ?? item.id)} - {@const meta = item.metadata ?? {}} - {@const isFailed = meta.queueStatus === 'failed'} - {@const progress = meta.downloadProgress ?? 0} - - - - - - - - - - {/each} - -
TitleServiceQualityProgressETAStatusActions
-
- {item.title ?? 'Unknown'} - {#if meta.errorMessage} - {meta.errorMessage} - {/if} -
-
- {meta.serviceName ?? '--'} - - {meta.quality ?? '--'} - -
-
-
-
- {formatSize(meta.sizeBytes)} -
-
- {formatEta(meta.eta)} - -
- - {statusLabel(meta.queueStatus, progress)} -
-
-
- {#if isFailed} - - - {:else} - - {/if} -
-
-
- {/if} -
- - diff --git a/src/lib/components/admin/QualityOverview.svelte b/src/lib/components/admin/QualityOverview.svelte deleted file mode 100644 index 2570dec4..00000000 --- a/src/lib/components/admin/QualityOverview.svelte +++ /dev/null @@ -1,191 +0,0 @@ - - -
-

- Library Quality -

- - {#if loading} - -
-
-
- {#each Array(3) as _} -
- {/each} -
-
- {#each Array(5) as _} -
- {/each} -
-
- {:else if error} -
-

{error}

-
- {:else if stats} -
- - {#if stats.withFile > 0} -
- {#each tierOrder as tier} - {@const count = stats.tiers[tier] ?? 0} - {#if count > 0} -
- {#if count / stats.withFile > 0.08}{tier}{/if} -
- {/if} - {/each} -
- {/if} - - -
-
-
{stats.total.toLocaleString()}
-
Total Items
-
-
-
{stats.withFile.toLocaleString()}
-
With Files
-
-
-
{stats.missing.toLocaleString()}
-
Missing
-
-
- - -
- {#each tierOrder as tier} - {@const count = stats.tiers[tier] ?? 0} - {#if count > 0} -
-
-
- {tier} -
-
-
-
- - {count.toLocaleString()} ({pct(count, stats.withFile)}%) - -
- {/if} - {/each} -
- - - {#if Object.keys(stats.byService).length > 0} -
-

- By Service -

-
- {#each Object.entries(stats.byService) as [name, svc]} -
- - - {#if expandedServices[name]} -
- {#each Object.entries(svc.qualities).sort((a, b) => b[1] - a[1]) as [quality, count]} -
- {quality} - {count} -
- {/each} - {#if Object.keys(svc.qualities).length === 0} -
No quality data
- {/if} -
- {/if} -
- {/each} -
-
- {/if} -
- {:else} -
-

No *arr services configured

-
- {/if} -
diff --git a/src/lib/components/auth/AuthCard.svelte b/src/lib/components/auth/AuthCard.svelte deleted file mode 100644 index 11760160..00000000 --- a/src/lib/components/auth/AuthCard.svelte +++ /dev/null @@ -1,34 +0,0 @@ - - -
-
- - - - -
-

{title}

- {#if subtitle} -

{subtitle}

- {/if} - -
- {@render children()} -
-
-
-
diff --git a/src/lib/components/auth/PasswordStrengthMeter.svelte b/src/lib/components/auth/PasswordStrengthMeter.svelte deleted file mode 100644 index 6f7b1e38..00000000 --- a/src/lib/components/auth/PasswordStrengthMeter.svelte +++ /dev/null @@ -1,45 +0,0 @@ - - -{#if password} -
-
- {#each [1, 2, 3, 4] as segment} -
- {/each} -
-

- {label} -

-
-{/if} diff --git a/src/lib/components/books/AnnotationPopup.svelte b/src/lib/components/books/AnnotationPopup.svelte deleted file mode 100644 index 3082bad3..00000000 --- a/src/lib/components/books/AnnotationPopup.svelte +++ /dev/null @@ -1,126 +0,0 @@ - - - -
{}}>
- - - - diff --git a/src/lib/components/books/AuthorCard.svelte b/src/lib/components/books/AuthorCard.svelte deleted file mode 100644 index 47dd4281..00000000 --- a/src/lib/components/books/AuthorCard.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - - - -
- {#if representativeCover} - - {:else} -
- {author.name.charAt(0).toUpperCase()} -
- {/if} -
- - -
-

{author.name}

-

{author.bookCount} book{author.bookCount === 1 ? '' : 's'}

-
-
diff --git a/src/lib/components/books/BookCardSkeleton.svelte b/src/lib/components/books/BookCardSkeleton.svelte deleted file mode 100644 index 5c20c4fb..00000000 --- a/src/lib/components/books/BookCardSkeleton.svelte +++ /dev/null @@ -1,27 +0,0 @@ -
-
-
- - diff --git a/src/lib/components/books/BookHero.svelte b/src/lib/components/books/BookHero.svelte deleted file mode 100644 index 071b9b04..00000000 --- a/src/lib/components/books/BookHero.svelte +++ /dev/null @@ -1,132 +0,0 @@ - - -
- - {#if book.poster} - - {/if} -
-
- - -
- - {#if book.poster} -
- {book.title} -
- {/if} - - -
- {#if seriesInfo} -

- {seriesInfo} -

- {/if} - -

- {book.title} -

- - {#if authorName} -

- by {authorName} -

- {/if} - -
- {#if book.year} - {book.year} - {/if} - {#if book.rating} - · - - - {book.rating.toFixed(1)} - - {/if} -
- - {#if book.genres && book.genres.length > 0} -
- {#each book.genres.slice(0, 4) as genre} - {genre} - {/each} -
- {/if} - - {#if book.description} - - {/if} - - -
-
-
- - diff --git a/src/lib/components/books/BookListRow.svelte b/src/lib/components/books/BookListRow.svelte deleted file mode 100644 index edcbe0a2..00000000 --- a/src/lib/components/books/BookListRow.svelte +++ /dev/null @@ -1,90 +0,0 @@ - - - - -
- {#if item.poster && !imgError} - {item.title} (imgError = true)} - loading="lazy" - /> - {:else} -
- - - -
- {/if} -
- - -
-
-

{item.title}

- {#if isRead} - Read - {/if} -
-
- {#if authorName} - {authorName} - {/if} - {#if seriesInfo} - · - {seriesInfo} - {/if} -
-
- - - - - - - - - {#if item.progress != null && item.progress > 0 && item.progress < 1} - - {/if} -
diff --git a/src/lib/components/books/BookReader.svelte b/src/lib/components/books/BookReader.svelte deleted file mode 100644 index 4399609c..00000000 --- a/src/lib/components/books/BookReader.svelte +++ /dev/null @@ -1,1271 +0,0 @@ - - - - - -
- - -
-
{book.title}
- {#if currentChapter} -
{currentChapter}
- {/if} -
- -
- - - - - - - {#if otherFormats.length > 0} -
- - {#if showFormatMenu} - -
- {#each availableFormats as fmt} - - {/each} -
- {/if} -
- {/if} - - -
-
- - - -
- -
- view?.prev()} - onNext={() => view?.next()} - onToggleUI={() => { showSettings = !showSettings; }} - > - {#snippet children(_ctx: { effectiveSpread: 'single' | 'dual'; animationKey: number })} -
- {/snippet} -
-
- - - - -
- - -
-
- - -
- -
- - -
- - {#if totalPages > 0 && remainingPages > 0} -
- -
- {/if} -
- - -{#if showToc} -
showToc = false} onkeydown={(e) => { if (e.key === 'Escape') showToc = false; }} role="button" tabindex="-1" aria-label="Close table of contents"> - -
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} - > -
-

Contents

- -
- -
-
-{/if} - - -{#if showSettings} -
showSettings = false} onkeydown={(e) => { if (e.key === 'Escape') showSettings = false; }} role="button" tabindex="-1" aria-label="Close settings"> - -
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} - > -
-

Reading Settings

- -
- - -
- Theme -
- {#each [{ key: 'light', label: 'Light', bg: '#faf8f5', ring: '#ccc' }, { key: 'sepia', label: 'Sepia', bg: '#f4ecd8', ring: '#c4a96a' }, { key: 'dark', label: 'Dark', bg: '#181514', ring: '#555' }, { key: 'oled', label: 'OLED', bg: '#000000', ring: '#333' }] as { key, label, bg, ring } (key)} - - {/each} -
-
- - -
- Font -
- {#each [{ key: 'serif', label: 'Serif', font: 'Georgia, serif' }, { key: 'sans', label: 'Sans', font: 'system-ui, sans-serif' }, { key: 'mono', label: 'Mono', font: "'JetBrains Mono', monospace" }, { key: 'display', label: 'Display', font: "'Playfair Display', Georgia, serif" }] as { key, label, font } (key)} - - {/each} -
-
- - -
- -
- - - -
-
- - -
- - -
- - -
- Margins -
- {#each [{ key: 'narrow', label: 'Narrow' }, { key: 'medium', label: 'Medium' }, { key: 'wide', label: 'Wide' }] as { key, label } (key)} - - {/each} -
-
- - -
- Alignment -
- - -
-
- - - -
-
-{/if} - - -{#if showBookmarks} -
showBookmarks = false} onkeydown={(e) => { if (e.key === 'Escape') showBookmarks = false; }} role="button" tabindex="-1" aria-label="Close bookmarks"> - -
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} - > -
-

Bookmarks

-
- - -
-
- {#if bookmarkList.length === 0} -

No bookmarks yet. Press B to bookmark.

- {:else} -
- {#each bookmarkList as bm} - - {/each} -
- {/if} - - {#if highlightList.length > 0} -
-

Highlights

-
- {#each highlightList as hl} - - {/each} -
-
- {/if} -
-
-{/if} - - -{#if showSearch} -
showSearch = false} onkeydown={(e) => { if (e.key === 'Escape') showSearch = false; }} role="button" tabindex="-1" aria-label="Close search"> - -
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} - > -
-
- - - { if (e.key === 'Enter') doSearch(); }} - autofocus - /> - {#if searching} -
- {/if} - -
- {#if searchResults.length > 0} -
- {#each searchResults as result} - - {/each} -
- {:else if searchQuery && !searching} -

Press Enter to search

- {/if} -
-
-
-{/if} - - -{#if highlightPopup} -
highlightPopup = null} onkeydown={(e) => { if (e.key === 'Escape') highlightPopup = null; }} role="button" tabindex="-1" aria-label="Close highlight menu"> - -
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} - > - {#each Object.entries(highlightColors) as [color, fill]} - - {/each} - -
-
-{/if} - - -{#if showAnnotationPopup} -
- {#if showNoteInput} - -
{ if (e.key === 'Escape') dismissAnnotationPopup(); }} role="button" tabindex="-1" aria-label="Close note input"> - -
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} - > -
Add a note
-
"{selectedText}"
- -
- - -
-
-
- {:else} - - {/if} -
-{/if} - - - showShortcuts = false} -/> - - -{#if showFormatMenu} -
showFormatMenu = false} onkeydown={(e) => { if (e.key === 'Escape') showFormatMenu = false; }} role="button" tabindex="-1" aria-label="Close format menu">
-{/if} - - -{#if loading} -
-
-
-

Loading book...

-
-
-{/if} - - -{#if loadError} -
-
-
!
-

Failed to load book

-

{loadError}

- -
-
-{/if} - - diff --git a/src/lib/components/books/BookshelfView.svelte b/src/lib/components/books/BookshelfView.svelte deleted file mode 100644 index 539d1e38..00000000 --- a/src/lib/components/books/BookshelfView.svelte +++ /dev/null @@ -1,77 +0,0 @@ - - -
- {#each shelves() as shelf, shelfIdx} -
- -
- {#each shelf as book (book.id)} - {@const detailUrl = `/media/${book.type}/${book.sourceId}?service=${book.serviceId}`} - -
- {#if book.poster} - {book.title} - {:else} -
- {book.title} -
- {/if} - -
-
- -
- {book.title} -
-
- {/each} -
- -
-
- -
-
-
- {/each} -
- - diff --git a/src/lib/components/books/KeyboardShortcuts.svelte b/src/lib/components/books/KeyboardShortcuts.svelte deleted file mode 100644 index 17f87adf..00000000 --- a/src/lib/components/books/KeyboardShortcuts.svelte +++ /dev/null @@ -1,98 +0,0 @@ - - - - -{#if visible} -
-
Keyboard Shortcuts
-
- {#each shortcuts as shortcut, i (i)} -
- {shortcut.label} - {shortcut.key} -
- {/each} -
-
-{/if} - - diff --git a/src/lib/components/books/MarginNotes.svelte b/src/lib/components/books/MarginNotes.svelte deleted file mode 100644 index 90ab28a1..00000000 --- a/src/lib/components/books/MarginNotes.svelte +++ /dev/null @@ -1,121 +0,0 @@ - - -{#if pageHighlights.length > 0 || pageNotes.length > 0} -
- {#each pageHighlights as hl, i (i)} -
-
-
- Highlight · p.{hl.page} -
-
{hl.text}
- {#if hl.createdAt} -
{formatTime(hl.createdAt)}
- {/if} -
- {/each} - - {#each pageNotes as note, i (i)} -
-
-
Note · p.{note.page}
-
{note.content}
- {#if note.createdAt} -
{formatTime(note.createdAt)}
- {/if} -
- {/each} -
-{/if} - - diff --git a/src/lib/components/books/PaginatedViewport.svelte b/src/lib/components/books/PaginatedViewport.svelte deleted file mode 100644 index 5077ea50..00000000 --- a/src/lib/components/books/PaginatedViewport.svelte +++ /dev/null @@ -1,110 +0,0 @@ - - -
- -
- {@render children({ effectiveSpread, animationKey })} -
- {#if settings.flow === 'paginated' && settings.inputs.tapZones} - - {/if} -
- - diff --git a/src/lib/components/books/PdfMinimap.svelte b/src/lib/components/books/PdfMinimap.svelte deleted file mode 100644 index a3e04b02..00000000 --- a/src/lib/components/books/PdfMinimap.svelte +++ /dev/null @@ -1,119 +0,0 @@ - - -
-
- {#each Array(numPages) as _, i (i)} - {@const page = i + 1} - - {/each} -
-
- - diff --git a/src/lib/components/books/PdfReader.svelte b/src/lib/components/books/PdfReader.svelte deleted file mode 100644 index 475f2cc3..00000000 --- a/src/lib/components/books/PdfReader.svelte +++ /dev/null @@ -1,1700 +0,0 @@ - - - - -{#snippet pageCard(pageNum: number)} - {@const dims = getPageDims(pageNum - 1)} -
- -
- {#if !renderedPages.has(pageNum)} -
- {pageNum} -
- {/if} - {#if localBookmarks.has(pageNum)} -
- {/if} - {#if highlightedPages.has(pageNum)} -
- {/if} -
-{/snippet} - - - - diff --git a/src/lib/components/books/PdfSidebar.svelte b/src/lib/components/books/PdfSidebar.svelte deleted file mode 100644 index 7cc58bcf..00000000 --- a/src/lib/components/books/PdfSidebar.svelte +++ /dev/null @@ -1,398 +0,0 @@ - - - - -{#snippet outlineNode(item: OutlineItem, depth: number)} - - {#if item.items} - {#each item.items as child (child.title)} - {@render outlineNode(child, depth + 1)} - {/each} - {/if} -{/snippet} - - diff --git a/src/lib/components/books/PdfToolbar.svelte b/src/lib/components/books/PdfToolbar.svelte deleted file mode 100644 index d1f515c9..00000000 --- a/src/lib/components/books/PdfToolbar.svelte +++ /dev/null @@ -1,427 +0,0 @@ - - - - - diff --git a/src/lib/components/books/ReaderProgressBar.svelte b/src/lib/components/books/ReaderProgressBar.svelte deleted file mode 100644 index a2464435..00000000 --- a/src/lib/components/books/ReaderProgressBar.svelte +++ /dev/null @@ -1,220 +0,0 @@ - - -
-
- - / {totalPages} -
- - - -
-
-
-
- - {#each chapters as chapter, i (i)} -
- {/each} -
- -
-
- -
{percentText}
-
- - diff --git a/src/lib/components/books/ReaderSettingsPanel.svelte b/src/lib/components/books/ReaderSettingsPanel.svelte deleted file mode 100644 index 16950dcf..00000000 --- a/src/lib/components/books/ReaderSettingsPanel.svelte +++ /dev/null @@ -1,162 +0,0 @@ - - -
-
-

Page flow

-
- {#each flowOptions as opt} - - {/each} -
-
- -
-

Spread

-
- {#each spreadOptions as opt} - - {/each} -
-

Auto picks dual on tablets/desktop, single on phones.

-
- -
-

Page animation

-
- {#each animOptions as opt} - - {/each} -
-
- -
-

Inputs

- - - -
- -
-

Direction

-
- {#each dirOptions as opt} - - {/each} -
-
- - {#if variant === 'epub'} -
-

Font size

- -

{settings.fontSize}px

-
- {/if} -
- - diff --git a/src/lib/components/books/ReadingRuler.svelte b/src/lib/components/books/ReadingRuler.svelte deleted file mode 100644 index 82345b1f..00000000 --- a/src/lib/components/books/ReadingRuler.svelte +++ /dev/null @@ -1,43 +0,0 @@ - - -
-
-
- - diff --git a/src/lib/components/books/ReadingStatsCard.svelte b/src/lib/components/books/ReadingStatsCard.svelte deleted file mode 100644 index 64b90863..00000000 --- a/src/lib/components/books/ReadingStatsCard.svelte +++ /dev/null @@ -1,67 +0,0 @@ - - -
-
- {booksThisYear} - books this year -
-
-
- {pagesThisMonth} - pages this month -
-
-
- {currentStreak} - day streak -
-
- - diff --git a/src/lib/components/books/SeriesCard.svelte b/src/lib/components/books/SeriesCard.svelte deleted file mode 100644 index 021af338..00000000 --- a/src/lib/components/books/SeriesCard.svelte +++ /dev/null @@ -1,45 +0,0 @@ - - - - -
- {#if covers.length >= 3} - - - - {:else if covers.length >= 1} - - {:else} -
- - - -
- {/if} -
- - -
-

{series.name}

-
- {series.books.length} book{series.books.length === 1 ? '' : 's'} - {#if readCount > 0} - Read {readCount} of {series.books.length} - {/if} -
-
-
diff --git a/src/lib/components/books/SeriesCollapsedCard.svelte b/src/lib/components/books/SeriesCollapsedCard.svelte deleted file mode 100644 index 6fa01d78..00000000 --- a/src/lib/components/books/SeriesCollapsedCard.svelte +++ /dev/null @@ -1,85 +0,0 @@ - - - - - diff --git a/src/lib/components/books/TimeEstimate.svelte b/src/lib/components/books/TimeEstimate.svelte deleted file mode 100644 index b41edea4..00000000 --- a/src/lib/components/books/TimeEstimate.svelte +++ /dev/null @@ -1,46 +0,0 @@ - - -
- - - - - {timeText} -
- - diff --git a/src/lib/components/books/__tests__/reader-settings.test.ts b/src/lib/components/books/__tests__/reader-settings.test.ts deleted file mode 100644 index ecdf64d3..00000000 --- a/src/lib/components/books/__tests__/reader-settings.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { - DEFAULT_READER_SETTINGS, - loadReaderSettings, - persistReaderSettings, - resolveSpread, - type ReaderSettings -} from '../reader-settings'; - -describe('reader-settings', () => { - beforeEach(() => { - localStorage.clear(); - }); - - it('returns defaults when nothing is stored', () => { - expect(loadReaderSettings()).toEqual(DEFAULT_READER_SETTINGS); - }); - - it('round-trips persisted settings', () => { - const patch: Partial = { - flow: 'scrolled', - spread: 'dual', - pageAnimation: 'fade', - direction: 'rtl', - inputs: { tapZones: false, swipe: true, keyboard: true } - }; - persistReaderSettings(patch); - const loaded = loadReaderSettings(); - expect(loaded.flow).toBe('scrolled'); - expect(loaded.spread).toBe('dual'); - expect(loaded.pageAnimation).toBe('fade'); - expect(loaded.direction).toBe('rtl'); - expect(loaded.inputs.tapZones).toBe(false); - }); - - it('preserves unrelated existing fields when persisting a partial patch', () => { - persistReaderSettings({ fontSize: 22, theme: 'sepia' as ReaderSettings['theme'] }); - persistReaderSettings({ flow: 'scrolled' }); - const loaded = loadReaderSettings(); - expect(loaded.fontSize).toBe(22); - expect(loaded.theme).toBe('sepia'); - expect(loaded.flow).toBe('scrolled'); - }); - - it('falls back to defaults for missing keys in stored JSON', () => { - localStorage.setItem('nexus-reader-settings', JSON.stringify({ flow: 'scrolled' })); - const loaded = loadReaderSettings(); - expect(loaded.flow).toBe('scrolled'); - expect(loaded.spread).toBe(DEFAULT_READER_SETTINGS.spread); - expect(loaded.inputs).toEqual(DEFAULT_READER_SETTINGS.inputs); - }); - - it('coerces invalid stored values back to defaults', () => { - localStorage.setItem('nexus-reader-settings', JSON.stringify({ flow: 'banana', spread: 42 })); - const loaded = loadReaderSettings(); - expect(loaded.flow).toBe(DEFAULT_READER_SETTINGS.flow); - expect(loaded.spread).toBe(DEFAULT_READER_SETTINGS.spread); - }); - - describe('resolveSpread', () => { - it('returns single below 768px when auto', () => { - expect(resolveSpread('auto', 600)).toBe('single'); - }); - it('returns dual at or above 768px when auto', () => { - expect(resolveSpread('auto', 1024)).toBe('dual'); - }); - it('respects explicit single regardless of width', () => { - expect(resolveSpread('single', 1920)).toBe('single'); - }); - it('respects explicit dual regardless of width', () => { - expect(resolveSpread('dual', 320)).toBe('dual'); - }); - }); -}); diff --git a/src/lib/components/books/__tests__/useReaderInputs.test.ts b/src/lib/components/books/__tests__/useReaderInputs.test.ts deleted file mode 100644 index 3afd373c..00000000 --- a/src/lib/components/books/__tests__/useReaderInputs.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { hitZone, isHorizontalSwipe, mapKeyToAction, flipForRtl } from '../useReaderInputs.svelte'; - -describe('useReaderInputs helpers', () => { - describe('hitZone', () => { - it('maps left third to prev', () => { - expect(hitZone(50, 1000)).toBe('prev'); - }); - it('maps middle third to toggleUI', () => { - expect(hitZone(500, 1000)).toBe('toggleUI'); - }); - it('maps right third to next', () => { - expect(hitZone(900, 1000)).toBe('next'); - }); - it('handles small viewports', () => { - expect(hitZone(100, 360)).toBe('prev'); - expect(hitZone(180, 360)).toBe('toggleUI'); - expect(hitZone(300, 360)).toBe('next'); - }); - }); - - describe('isHorizontalSwipe', () => { - it('returns prev for rightward swipe past threshold', () => { - expect(isHorizontalSwipe({ dx: 80, dy: 10 })).toBe('prev'); - }); - it('returns next for leftward swipe past threshold', () => { - expect(isHorizontalSwipe({ dx: -80, dy: 10 })).toBe('next'); - }); - it('returns null when below threshold', () => { - expect(isHorizontalSwipe({ dx: 30, dy: 5 })).toBeNull(); - }); - it('returns null when vertical travel dominates', () => { - expect(isHorizontalSwipe({ dx: 60, dy: 200 })).toBeNull(); - }); - }); - - describe('mapKeyToAction', () => { - it('maps ArrowLeft to prev', () => { - expect(mapKeyToAction('ArrowLeft')).toBe('prev'); - }); - it('maps ArrowRight to next', () => { - expect(mapKeyToAction('ArrowRight')).toBe('next'); - }); - it('maps PageUp to prev, PageDown and Space to next', () => { - expect(mapKeyToAction('PageUp')).toBe('prev'); - expect(mapKeyToAction('PageDown')).toBe('next'); - expect(mapKeyToAction(' ')).toBe('next'); - }); - it('returns null for irrelevant keys', () => { - expect(mapKeyToAction('a')).toBeNull(); - expect(mapKeyToAction('Enter')).toBeNull(); - }); - }); - - describe('flipForRtl', () => { - it('swaps prev and next when direction is rtl', () => { - expect(flipForRtl('prev', 'rtl')).toBe('next'); - expect(flipForRtl('next', 'rtl')).toBe('prev'); - expect(flipForRtl('toggleUI', 'rtl')).toBe('toggleUI'); - }); - it('passes through when direction is ltr', () => { - expect(flipForRtl('prev', 'ltr')).toBe('prev'); - expect(flipForRtl('next', 'ltr')).toBe('next'); - }); - }); -}); diff --git a/src/lib/components/books/reader-settings.ts b/src/lib/components/books/reader-settings.ts deleted file mode 100644 index 0e3301ad..00000000 --- a/src/lib/components/books/reader-settings.ts +++ /dev/null @@ -1,115 +0,0 @@ -export type ReaderThemeName = 'light' | 'dark' | 'sepia' | 'oled'; -export type FontFamilyName = 'serif' | 'sans' | 'mono' | 'display'; -export type MarginName = 'narrow' | 'medium' | 'wide'; - -export interface ReaderSettings { - theme: ReaderThemeName; - fontFamily: FontFamilyName; - fontSize: number; - lineHeight: number; - margins: MarginName; - textAlign: 'start' | 'justify'; - flow: 'paginated' | 'scrolled'; - spread: 'auto' | 'single' | 'dual'; - pageAnimation: 'slide' | 'fade' | 'none'; - inputs: { - tapZones: boolean; - swipe: boolean; - keyboard: boolean; - }; - direction: 'ltr' | 'rtl'; -} - -export const DEFAULT_READER_SETTINGS: ReaderSettings = { - theme: 'dark', - fontFamily: 'serif', - fontSize: 18, - lineHeight: 1.5, - margins: 'medium', - textAlign: 'start', - flow: 'paginated', - spread: 'auto', - pageAnimation: 'slide', - inputs: { tapZones: true, swipe: true, keyboard: true }, - direction: 'ltr' -}; - -const STORAGE_KEY = 'nexus-reader-settings'; - -const FLOW_VALUES = new Set(['paginated', 'scrolled'] as const); -const SPREAD_VALUES = new Set(['auto', 'single', 'dual'] as const); -const ANIM_VALUES = new Set(['slide', 'fade', 'none'] as const); -const DIR_VALUES = new Set(['ltr', 'rtl'] as const); -const ALIGN_VALUES = new Set(['start', 'justify'] as const); -const THEME_VALUES = new Set(['light', 'dark', 'sepia', 'oled'] as const); -const FONT_VALUES = new Set(['serif', 'sans', 'mono', 'display'] as const); -const MARGIN_VALUES = new Set(['narrow', 'medium', 'wide'] as const); - -function pick(value: unknown, allowed: Set, fallback: T): T { - return typeof value === 'string' && allowed.has(value as T) ? (value as T) : fallback; -} - -function pickNumber(value: unknown, fallback: number, min: number, max: number): number { - if (typeof value !== 'number' || !Number.isFinite(value)) return fallback; - if (value < min || value > max) return fallback; - return value; -} - -function pickBool(value: unknown, fallback: boolean): boolean { - return typeof value === 'boolean' ? value : fallback; -} - -function coerce(raw: unknown): ReaderSettings { - const r = (raw && typeof raw === 'object' ? raw : {}) as Record; - const inputsRaw = (r.inputs && typeof r.inputs === 'object' ? r.inputs : {}) as Record; - return { - theme: pick(r.theme, THEME_VALUES, DEFAULT_READER_SETTINGS.theme), - fontFamily: pick(r.fontFamily, FONT_VALUES, DEFAULT_READER_SETTINGS.fontFamily), - fontSize: pickNumber(r.fontSize, DEFAULT_READER_SETTINGS.fontSize, 10, 36), - lineHeight: pickNumber(r.lineHeight, DEFAULT_READER_SETTINGS.lineHeight, 1.0, 2.5), - margins: pick(r.margins, MARGIN_VALUES, DEFAULT_READER_SETTINGS.margins), - textAlign: pick(r.textAlign, ALIGN_VALUES, DEFAULT_READER_SETTINGS.textAlign), - flow: pick(r.flow, FLOW_VALUES, DEFAULT_READER_SETTINGS.flow), - spread: pick(r.spread, SPREAD_VALUES, DEFAULT_READER_SETTINGS.spread), - pageAnimation: pick(r.pageAnimation, ANIM_VALUES, DEFAULT_READER_SETTINGS.pageAnimation), - inputs: { - tapZones: pickBool(inputsRaw.tapZones, DEFAULT_READER_SETTINGS.inputs.tapZones), - swipe: pickBool(inputsRaw.swipe, DEFAULT_READER_SETTINGS.inputs.swipe), - keyboard: pickBool(inputsRaw.keyboard, DEFAULT_READER_SETTINGS.inputs.keyboard) - }, - direction: pick(r.direction, DIR_VALUES, DEFAULT_READER_SETTINGS.direction) - }; -} - -export function loadReaderSettings(): ReaderSettings { - if (typeof localStorage === 'undefined') return { ...DEFAULT_READER_SETTINGS }; - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return { ...DEFAULT_READER_SETTINGS }; - return coerce(JSON.parse(raw)); - } catch { - return { ...DEFAULT_READER_SETTINGS }; - } -} - -export function persistReaderSettings(patch: Partial): void { - if (typeof localStorage === 'undefined') return; - const current = loadReaderSettings(); - const next: ReaderSettings = { - ...current, - ...patch, - inputs: { ...current.inputs, ...(patch.inputs ?? {}) } - }; - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); - } catch { - /* quota or privacy mode — ignore */ - } -} - -const SPREAD_BREAKPOINT_PX = 768; - -export function resolveSpread(spread: ReaderSettings['spread'], viewportWidth: number): 'single' | 'dual' { - if (spread === 'single' || spread === 'dual') return spread; - return viewportWidth >= SPREAD_BREAKPOINT_PX ? 'dual' : 'single'; -} diff --git a/src/lib/components/books/useReaderInputs.svelte.ts b/src/lib/components/books/useReaderInputs.svelte.ts deleted file mode 100644 index 252dcfea..00000000 --- a/src/lib/components/books/useReaderInputs.svelte.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { ReaderSettings } from './reader-settings'; - -export type ReaderAction = 'prev' | 'next' | 'toggleUI'; - -const SWIPE_MIN_PX = 50; -const SWIPE_VERTICAL_RATIO = 2; // |dx| must be > 2*|dy| - -export function hitZone(x: number, width: number): ReaderAction { - const third = width / 3; - if (x < third) return 'prev'; - if (x < 2 * third) return 'toggleUI'; - return 'next'; -} - -export function isHorizontalSwipe({ dx, dy }: { dx: number; dy: number }): ReaderAction | null { - if (Math.abs(dx) < SWIPE_MIN_PX) return null; - if (Math.abs(dx) <= Math.abs(dy) * SWIPE_VERTICAL_RATIO) return null; - return dx > 0 ? 'prev' : 'next'; -} - -export function mapKeyToAction(key: string): ReaderAction | null { - switch (key) { - case 'ArrowLeft': - case 'PageUp': - return 'prev'; - case 'ArrowRight': - case 'PageDown': - case ' ': - return 'next'; - default: - return null; - } -} - -export function flipForRtl(action: A, direction: ReaderSettings['direction']): A { - if (direction !== 'rtl') return action; - if (action === 'prev') return 'next' as A; - if (action === 'next') return 'prev' as A; - return action; -} - -interface UseInputsArgs { - getSettings: () => Pick; - onPrev: () => void; - onNext: () => void; - onToggleUI: () => void; -} - -export function useReaderInputs(args: UseInputsArgs) { - const dispatch = (action: ReaderAction) => { - const { direction } = args.getSettings(); - const final = flipForRtl(action, direction); - if (final === 'prev') args.onPrev(); - else if (final === 'next') args.onNext(); - else if (final === 'toggleUI') args.onToggleUI(); - }; - - const tapHandlers = { - onpointerup(e: PointerEvent) { - if (!args.getSettings().inputs.tapZones) return; - const target = e.currentTarget as HTMLElement; - const rect = target.getBoundingClientRect(); - dispatch(hitZone(e.clientX - rect.left, rect.width)); - } - }; - - let touchStart: { x: number; y: number } | null = null; - const swipeHandlers = { - ontouchstart(e: TouchEvent) { - if (!args.getSettings().inputs.swipe) return; - const t = e.changedTouches[0]; - touchStart = { x: t.clientX, y: t.clientY }; - }, - ontouchend(e: TouchEvent) { - if (!args.getSettings().inputs.swipe || !touchStart) return; - const t = e.changedTouches[0]; - const dx = t.clientX - touchStart.x; - const dy = t.clientY - touchStart.y; - touchStart = null; - const action = isHorizontalSwipe({ dx, dy }); - if (action) dispatch(action); - } - }; - - function attachKeyboard(target: Window | HTMLElement = window): () => void { - const onKey = (e: KeyboardEvent) => { - if (!args.getSettings().inputs.keyboard) return; - const action = mapKeyToAction(e.key); - if (!action) return; - e.preventDefault(); - dispatch(action); - }; - target.addEventListener('keydown', onKey as EventListener); - return () => target.removeEventListener('keydown', onKey as EventListener); - } - - return { tapHandlers, swipeHandlers, attachKeyboard }; -} diff --git a/src/lib/components/charts/ActivityCalendar.svelte b/src/lib/components/charts/ActivityCalendar.svelte deleted file mode 100644 index 1fb61c87..00000000 --- a/src/lib/components/charts/ActivityCalendar.svelte +++ /dev/null @@ -1,100 +0,0 @@ - - -
- -
- {#each streakStats as stat (stat.label)} -
-

{stat.value}

-

{stat.label}

-
- {/each} -
- - -
-
- -
- {#each DAY_LABELS as label} -
- {label} -
- {/each} -
- - - {#each weeks as week, wi (wi)} -
- {#each week as cell (cell.date)} -
- {/each} -
- {/each} -
-
-
diff --git a/src/lib/components/charts/DeviceBreakdown.svelte b/src/lib/components/charts/DeviceBreakdown.svelte deleted file mode 100644 index 544d4e08..00000000 --- a/src/lib/components/charts/DeviceBreakdown.svelte +++ /dev/null @@ -1,53 +0,0 @@ - - -{#snippet segmentBar(segments: { name: string; pct: number; color: string }[], label: string)} -
-

{label}

- {#if segments.length === 0} -

No data

- {:else} -
- {#each segments as seg (seg.name)} -
- {#if seg.pct >= 15}{seg.name}{/if} -
- {/each} -
-
- {#each segments as seg (seg.name)} -
- - {seg.name} · {seg.pct}% -
- {/each} -
- {/if} -
-{/snippet} - -{@render segmentBar(devices, 'Devices')} -{@render segmentBar(clients, 'Clients')} diff --git a/src/lib/components/charts/GenreBars.svelte b/src/lib/components/charts/GenreBars.svelte deleted file mode 100644 index 5a3c9d57..00000000 --- a/src/lib/components/charts/GenreBars.svelte +++ /dev/null @@ -1,41 +0,0 @@ - - -
- {#if genres.length === 0} -

No genre data in this period.

- {:else} - {#each genres as genre, i (genre.genre)} -
- {genre.genre} -
-
- - {formatTime(genre.playTimeMs)} - -
-
- {/each} - {/if} -
diff --git a/src/lib/components/charts/MediaDonut.svelte b/src/lib/components/charts/MediaDonut.svelte deleted file mode 100644 index 7b3a4ffb..00000000 --- a/src/lib/components/charts/MediaDonut.svelte +++ /dev/null @@ -1,127 +0,0 @@ - - -
-
- -
-
-

{totalHours}h

-

total

-
-
-
-
- {#each chartInfo.labels as label, i (label)} -
- - {label} -
- {/each} -
-
diff --git a/src/lib/components/charts/QualityStats.svelte b/src/lib/components/charts/QualityStats.svelte deleted file mode 100644 index 7d170c77..00000000 --- a/src/lib/components/charts/QualityStats.svelte +++ /dev/null @@ -1,40 +0,0 @@ - - -
- {#each metrics as m (m.label)} -
-

{m.value}

-

{m.label}

-
- {/each} -
diff --git a/src/lib/components/charts/TopItems.svelte b/src/lib/components/charts/TopItems.svelte deleted file mode 100644 index f63579b6..00000000 --- a/src/lib/components/charts/TopItems.svelte +++ /dev/null @@ -1,54 +0,0 @@ - - -
- {#if items.length === 0} -

No activity in this period.

- {:else} - {#each items as item, i (item.mediaId)} -
- - {i + 1} - -
-

{item.title}

-

- {item.mediaType} - · {item.sessions} session{item.sessions !== 1 ? 's' : ''} -

-
- {formatTime(item.playTimeMs)} -
- {/each} - {/if} -
diff --git a/src/lib/components/charts/ViewingHeatmap.svelte b/src/lib/components/charts/ViewingHeatmap.svelte deleted file mode 100644 index 828269fd..00000000 --- a/src/lib/components/charts/ViewingHeatmap.svelte +++ /dev/null @@ -1,61 +0,0 @@ - - -
-
- -
- {#each Array(24) as _, h} - {#if h % 6 === 0} -
- {h === 0 ? '12a' : h === 6 ? '6a' : h === 12 ? '12p' : '6p'} -
- {:else} -
- {/if} - {/each} - - - {#each DAYS as day, d} -
{day}
- {#each Array(24) as _, h} - {@const cell = grid[d * 24 + h]} -
- {/each} - {/each} -
-
diff --git a/src/lib/components/charts/WatchTimeChart.svelte b/src/lib/components/charts/WatchTimeChart.svelte deleted file mode 100644 index 32ecabc0..00000000 --- a/src/lib/components/charts/WatchTimeChart.svelte +++ /dev/null @@ -1,109 +0,0 @@ - - -
- {#if dailyTimeline.length === 0} -
No activity in this period
- {:else} - - {/if} -
diff --git a/src/lib/components/games/AchievementCard.svelte b/src/lib/components/games/AchievementCard.svelte deleted file mode 100644 index ba0f9237..00000000 --- a/src/lib/components/games/AchievementCard.svelte +++ /dev/null @@ -1,133 +0,0 @@ - - -
-
- {#if achievement.badge_url} - - {:else} -
- - - -
- {/if} -
- -
- {achievement.title} - {#if achievement.description} - {achievement.description} - {/if} -
- - {#if achievement.points} - {achievement.points} - {/if} -
- - diff --git a/src/lib/components/games/AchievementProgress.svelte b/src/lib/components/games/AchievementProgress.svelte deleted file mode 100644 index c3d434b5..00000000 --- a/src/lib/components/games/AchievementProgress.svelte +++ /dev/null @@ -1,123 +0,0 @@ - - -
-
- - - - - {pct}% -
- -
- {unlocked} of {total} unlocked - {#if totalPoints > 0} - {earnedPoints} / {totalPoints} pts - {/if} -
-
- - diff --git a/src/lib/components/games/CollectionEditor.svelte b/src/lib/components/games/CollectionEditor.svelte deleted file mode 100644 index b6b6b1bc..00000000 --- a/src/lib/components/games/CollectionEditor.svelte +++ /dev/null @@ -1,210 +0,0 @@ - - -{#if open} - -
{ if (e.target === e.currentTarget) onclose?.(); }} - onkeydown={handleKeydown} - > -
- -
-
- -

- {collection ? 'Edit Collection' : 'New Collection'} -

-
- -
- -
- - - - - - - -
- -
- - -
- - - {#if searchResults.length > 0} -
- {#each searchResults as game (game.id)} - - {/each} -
- {/if} -
- - - {#if selectedGames.length > 0} -
- - Games in Collection ({selectedGames.length}) - -
- {#each selectedGames as game (game.id)} -
- {#if game.poster} - - {/if} - {game.title} - {#if game.metadata?.platform} - {game.metadata.platform} - {/if} - -
- {/each} -
-
- {/if} -
- - -
- - -
-
-
-{/if} diff --git a/src/lib/components/games/EmulatorToolbar.svelte b/src/lib/components/games/EmulatorToolbar.svelte deleted file mode 100644 index 2a9d74d9..00000000 --- a/src/lib/components/games/EmulatorToolbar.svelte +++ /dev/null @@ -1,91 +0,0 @@ - - -
- - - - - -
- - diff --git a/src/lib/components/games/FavoriteButton.svelte b/src/lib/components/games/FavoriteButton.svelte deleted file mode 100644 index a2a7d2c5..00000000 --- a/src/lib/components/games/FavoriteButton.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - - diff --git a/src/lib/components/games/GameFilterPanel.svelte b/src/lib/components/games/GameFilterPanel.svelte deleted file mode 100644 index fb1d6eb5..00000000 --- a/src/lib/components/games/GameFilterPanel.svelte +++ /dev/null @@ -1,370 +0,0 @@ - - -
- - {#if activeCount > 0} -
- {#each filters.genres as g} - - {/each} - {#each filters.statuses as s} - - {/each} - {#each filters.regions as r} - - {/each} - {#each filters.tags as t} - - {/each} - {#if filters.ratingMin != null} - - {/if} - {#if filters.ratingMax != null} - - {/if} - -
- {/if} - -
- - {#if allStatuses.length > 0} -
- Status -
- {#each allStatuses as s} - - {/each} -
-
- {/if} - - - {#if allGenres.length > 0} -
- Genre -
- {#each allGenres as g} - - {/each} -
-
- {/if} - - -
- Rating -
- setRatingMin(e.currentTarget.value)} - class="fp__input" - /> - - - setRatingMax(e.currentTarget.value)} - class="fp__input" - /> -
-
- - - {#if allRegions.length > 0} -
- Region -
- {#each allRegions as r} - - {/each} -
-
- {/if} - - - {#if allTags.length > 0} -
- Tags -
- {#each allTags as t} - - {/each} -
-
- {/if} -
-
- - diff --git a/src/lib/components/games/GameNotes.svelte b/src/lib/components/games/GameNotes.svelte deleted file mode 100644 index 061ff08a..00000000 --- a/src/lib/components/games/GameNotes.svelte +++ /dev/null @@ -1,130 +0,0 @@ - - -
-
- Notes - - {#if saveStatus === 'saving'} - Saving... - {:else if saveStatus === 'saved'} - Saved - {/if} - -
- - -
- - diff --git a/src/lib/components/games/GameSearchInput.svelte b/src/lib/components/games/GameSearchInput.svelte deleted file mode 100644 index a6e4c980..00000000 --- a/src/lib/components/games/GameSearchInput.svelte +++ /dev/null @@ -1,53 +0,0 @@ - - -
- - onchange?.(value)} - onkeydown={handleKeydown} - type="text" - {placeholder} - class="w-full rounded-xl border border-cream/[0.06] bg-cream/[0.03] px-3 py-1.5 pl-8 text-xs text-cream placeholder:text-faint/60 transition-all duration-300 focus:border-warm/30 focus:outline-none focus:ring-1 focus:ring-warm/30" - /> - {#if value} - - {/if} -
diff --git a/src/lib/components/games/HltbDisplay.svelte b/src/lib/components/games/HltbDisplay.svelte deleted file mode 100644 index ed0d5ea0..00000000 --- a/src/lib/components/games/HltbDisplay.svelte +++ /dev/null @@ -1,96 +0,0 @@ - - -
- {#each entries as entry} - {@const pct = (entry.value! / maxValue) * 100} -
- {entry.label} -
-
-
- {formatTime(entry.value)} -
- {/each} -
- - diff --git a/src/lib/components/history/HistoryFeed.svelte b/src/lib/components/history/HistoryFeed.svelte deleted file mode 100644 index 3d3929b0..00000000 --- a/src/lib/components/history/HistoryFeed.svelte +++ /dev/null @@ -1,158 +0,0 @@ - - -
diff --git a/src/lib/components/history/HistoryFilters.svelte b/src/lib/components/history/HistoryFilters.svelte deleted file mode 100644 index d0f3c4bf..00000000 --- a/src/lib/components/history/HistoryFilters.svelte +++ /dev/null @@ -1,108 +0,0 @@ - - -
- -
- {#each mediaTypes as mt (mt.id)} - {@const active = mt.id === 'all' ? isAllSelected : selectedTypes.includes(mt.id)} - - {/each} -
- - -
- - onfilter()} - class="w-full rounded-lg border border-cream/[0.06] bg-raised py-1.5 pl-8 pr-3 text-xs text-cream placeholder:text-faint" - /> -
- - - {#if services.length > 1} - - {/if} - - -
- - -
-
diff --git a/src/lib/components/history/HistoryTable.svelte b/src/lib/components/history/HistoryTable.svelte deleted file mode 100644 index 2bf7cf2d..00000000 --- a/src/lib/components/history/HistoryTable.svelte +++ /dev/null @@ -1,94 +0,0 @@ - - -
- {#if events.length === 0} -

No history yet.

- {:else} - - - - - - - - - - - - {#each sorted as event (event.id)} - - - - - - - - {/each} - -
toggleSort('title')}>Title{sortIndicator('title')} toggleSort('type')}>Type{sortIndicator('type')} toggleSort('duration')}>Duration{sortIndicator('duration')} toggleSort('service')}>Service{sortIndicator('service')} toggleSort('date')}>Date{sortIndicator('date')}
{event.mediaTitle ?? 'Untitled'}{event.mediaType}{formatDuration(event.durationMs)}{serviceNameMap.get(event.serviceId) ?? event.serviceId}{formatDate(event.timestamp)}
- {/if} -
diff --git a/src/lib/components/music/AddToPlaylistMenu.svelte b/src/lib/components/music/AddToPlaylistMenu.svelte deleted file mode 100644 index 4fdccec8..00000000 --- a/src/lib/components/music/AddToPlaylistMenu.svelte +++ /dev/null @@ -1,79 +0,0 @@ - - -
-
- Add to playlist - -
-
- {#each playlists as playlist (playlist.id)} - {@const isInPlaylist = playlist.trackIds.includes(trackId)} - - {/each} -
-
- -
-
diff --git a/src/lib/components/music/AlbumCard.svelte b/src/lib/components/music/AlbumCard.svelte deleted file mode 100644 index fcef8f3f..00000000 --- a/src/lib/components/music/AlbumCard.svelte +++ /dev/null @@ -1,169 +0,0 @@ - - - -
- {#if album.poster && !imageError} - {#if lowResSrc && !imgLoaded} - - {/if} - {album.title} (imgLoaded = true)} - onerror={() => (imageError = true)} - /> - {:else} -
- {/if} - - {#if onplay} - - {/if} -
- -
-

{album.title}

- {#if artist} -

{artist}

- {/if} -
-
- - diff --git a/src/lib/components/music/ArtistCard.svelte b/src/lib/components/music/ArtistCard.svelte deleted file mode 100644 index 53991841..00000000 --- a/src/lib/components/music/ArtistCard.svelte +++ /dev/null @@ -1,127 +0,0 @@ - - - -
- {#if artist.imageUrl && !imageError} - {#if lowResSrc && !imgLoaded} - - {/if} - {artist.name} (imgLoaded = true)} - onerror={() => (imageError = true)} - /> - {:else} -
- {/if} -
- -
-

{artist.name}

- {#if artist.albumCount != null} -

- {artist.albumCount} {artist.albumCount === 1 ? 'album' : 'albums'} -

- {/if} -
-
- - diff --git a/src/lib/components/music/CreatePlaylistModal.svelte b/src/lib/components/music/CreatePlaylistModal.svelte deleted file mode 100644 index 2ef62bd4..00000000 --- a/src/lib/components/music/CreatePlaylistModal.svelte +++ /dev/null @@ -1,87 +0,0 @@ - - -{#if open} - -
{ if (e.target === e.currentTarget) onclose?.(); }} - onkeydown={handleKeydown} - > -
-
-
- -

New Playlist

-
- -
- -
{ e.preventDefault(); handleSubmit(); }}> - - -
- - -
-
-
-
-{/if} diff --git a/src/lib/components/music/LikedSongsCard.svelte b/src/lib/components/music/LikedSongsCard.svelte deleted file mode 100644 index 328a16bc..00000000 --- a/src/lib/components/music/LikedSongsCard.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - - diff --git a/src/lib/components/music/MusicNav.svelte b/src/lib/components/music/MusicNav.svelte deleted file mode 100644 index 9b4b19bd..00000000 --- a/src/lib/components/music/MusicNav.svelte +++ /dev/null @@ -1,104 +0,0 @@ - - - - - diff --git a/src/lib/components/music/MusicPill.svelte b/src/lib/components/music/MusicPill.svelte deleted file mode 100644 index 27af46b8..00000000 --- a/src/lib/components/music/MusicPill.svelte +++ /dev/null @@ -1,418 +0,0 @@ - - - - -{#if musicPlayer.visible && track} - - - - showNowPlaying = false} /> -{/if} - - diff --git a/src/lib/components/music/NowPlayingOverlay.svelte b/src/lib/components/music/NowPlayingOverlay.svelte deleted file mode 100644 index 5e240977..00000000 --- a/src/lib/components/music/NowPlayingOverlay.svelte +++ /dev/null @@ -1,530 +0,0 @@ - - -{#if visible} - - - { showQueue = false; }} /> -{/if} - - diff --git a/src/lib/components/music/PlaylistCard.svelte b/src/lib/components/music/PlaylistCard.svelte deleted file mode 100644 index 3c45ff79..00000000 --- a/src/lib/components/music/PlaylistCard.svelte +++ /dev/null @@ -1,96 +0,0 @@ - - - diff --git a/src/lib/components/music/QueuePanel.svelte b/src/lib/components/music/QueuePanel.svelte deleted file mode 100644 index eaba4611..00000000 --- a/src/lib/components/music/QueuePanel.svelte +++ /dev/null @@ -1,344 +0,0 @@ - - -{#if visible} - - - - - -{/if} - - diff --git a/src/lib/components/music/TrackRow.svelte b/src/lib/components/music/TrackRow.svelte deleted file mode 100644 index 9f4a5803..00000000 --- a/src/lib/components/music/TrackRow.svelte +++ /dev/null @@ -1,108 +0,0 @@ - - - -
onplay?.()} - onkeydown={(e) => e.key === 'Enter' && onplay?.()} - role="button" - tabindex="0" -> - - - {#if isCurrent && isPlaying} - - - - - - {:else} - {index + 1} - - - - {#if showAlbumArt} -
- {track.album} -
- {/if} - - -
-

- {track.title} -

- {#if showArtist} -

{track.artist}

- {/if} -
- - - {#if showAlbum} - - {/if} - - - - - - {#if onaddtoplaylist} - - {/if} - - - - {formatTime(track.duration)} - -
diff --git a/src/lib/components/onboarding/GettingStartedChecklist.svelte b/src/lib/components/onboarding/GettingStartedChecklist.svelte deleted file mode 100644 index 5e0db176..00000000 --- a/src/lib/components/onboarding/GettingStartedChecklist.svelte +++ /dev/null @@ -1,176 +0,0 @@ - - -
- - - {#if !collapsed} -
- {#each groups as group (group.category)} - {#if group.adapters.length > 0} -
-

{group.label}

-
- {#each group.adapters as adapter (adapter.id)} - - {/each} -
-
- {/if} - {/each} - - - - - -
- - -
-
- {/if} -
diff --git a/src/lib/components/onboarding/ServiceCard.svelte b/src/lib/components/onboarding/ServiceCard.svelte deleted file mode 100644 index 81fd0b9d..00000000 --- a/src/lib/components/onboarding/ServiceCard.svelte +++ /dev/null @@ -1,346 +0,0 @@ - - -{#if hero} - -
- -
- - - - {#if expanded && !isComplete} -
- {#if error} -
- - {error} -
- {/if} - - {#if isPlex && !apiKey} - - {#if !plexPinCode} -

- Click below to sign in to Plex. We'll show you a 4-character code to enter at - plex.tv/link. -

- -

- Prefer to paste a token manually? - -

- {:else if plexPinCode === '_manual'} - - {:else} -
-

Your PIN

-

{plexPinCode}

-

- Enter this at plex.tv/link - {#if plexPolling} - - - Waiting... - - {/if} -

-
- {/if} - {/if} - - {#if onboarding.requiredFields.includes('url')} - - {/if} - - {#if onboarding.supportsAutoAuth} -
- {#if onboarding.requiredFields.includes('username')} - - {/if} - {#if onboarding.requiredFields.includes('password')} - - {/if} -
-

Nexus will authenticate automatically -- no API key needed.

- {:else if onboarding.requiredFields.includes('apiKey') && !isPlex} - - {/if} - - {#if !isPlex || apiKey} - - {/if} -
- {/if} -
- -{:else} - -
- - - {#if expanded && !isComplete} -
- {#if error} -
{error}
- {/if} - - {#if onboarding.requiredFields.includes('url')} - - {/if} - - {#if onboarding.supportsAutoAuth} -
- {#if onboarding.requiredFields.includes('username')} - - {/if} - {#if onboarding.requiredFields.includes('password')} - - {/if} -
- {:else if onboarding.requiredFields.includes('apiKey')} - - {/if} - - -
- {/if} -
-{/if} diff --git a/src/lib/components/onboarding/ServiceIcon.svelte b/src/lib/components/onboarding/ServiceIcon.svelte deleted file mode 100644 index 1da80864..00000000 --- a/src/lib/components/onboarding/ServiceIcon.svelte +++ /dev/null @@ -1,75 +0,0 @@ - - -{#if type === 'jellyfin'} - - - - -{:else if type === 'plex'} - - - - -{:else if type === 'radarr'} - - - - -{:else if type === 'sonarr'} - - - - -{:else if type === 'lidarr'} - - - - -{:else if type === 'overseerr' || type === 'seerr'} - - - - -{:else if type === 'bazarr'} - - - - -{:else if type === 'streamystats'} - - - - -{:else if type === 'invidious'} - - - - -{:else if type === 'romm'} - - - - -{:else if type === 'calibre'} - - - - -{:else if type === 'prowlarr'} - - - - -{:else} - - - - -{/if} diff --git a/src/lib/components/onboarding/SetupHint.svelte b/src/lib/components/onboarding/SetupHint.svelte deleted file mode 100644 index 5d84543d..00000000 --- a/src/lib/components/onboarding/SetupHint.svelte +++ /dev/null @@ -1,41 +0,0 @@ - - -{#each missing as m (m.category)} - {#if !dismissed.has(m.category)} -
-

- {m.description} — - - Connect {m.adapterName} - -

- -
- {/if} -{/each} diff --git a/src/lib/components/player/AudioMenu.svelte b/src/lib/components/player/AudioMenu.svelte deleted file mode 100644 index ddcca94c..00000000 --- a/src/lib/components/player/AudioMenu.svelte +++ /dev/null @@ -1,75 +0,0 @@ - - -
-
Audio
- - {#each tracks as t (t.id)} - - {/each} -
- - diff --git a/src/lib/components/player/ModePill.svelte b/src/lib/components/player/ModePill.svelte deleted file mode 100644 index 576bf090..00000000 --- a/src/lib/components/player/ModePill.svelte +++ /dev/null @@ -1,29 +0,0 @@ - - - - {label} - diff --git a/src/lib/components/player/NexusPlayer.svelte b/src/lib/components/player/NexusPlayer.svelte deleted file mode 100644 index 1d314f85..00000000 --- a/src/lib/components/player/NexusPlayer.svelte +++ /dev/null @@ -1,1284 +0,0 @@ - - - -
{ if ((e.target as HTMLElement).closest('.ctrl-panel-wrap, .ctrl-bar')) return; }} - role="application" - aria-label="Video player" -> - - - - {#if isAudio && poster} -
- -
-
- {/if} - - {#if !hasStarted} - {#if !inline && !isAudio} - - {/if} - - {/if} - - {#if ps.isLoading && hasStarted} -
- {/if} - - {#if ps.error} -
- -

{ps.error}

- -
- {/if} - - {#if hasStarted && !ps.error} - - {/if} - - {#if hasStarted && !ps.error} -
- -
- {#if !isAudio && (!inline || isFullscreen)} - - {/if} -
- {#if title}{title}{/if} - {#if subtitle}{subtitle}{/if} -
- - {ps.qualityLabel} - {#if ps.measuredBandwidth > 0} - - {(ps.measuredBandwidth / 1_000_000).toFixed(1)} Mbps - - {/if} -
- -
- - -
(scrubHover = false)} - role="slider" - aria-label="Seek" - aria-valuemin={0} - aria-valuemax={ps.duration} - aria-valuenow={ps.currentTime} - tabindex={0} - > -
-
-
-
-
- {#if scrubHover && !isScrubbing} -
{fmt(scrubHoverFrac * ps.duration)}
- {/if} - {#if isScrubbing} -
{fmt(scrubPreview * ps.duration)}
- {/if} -
- -
- - - - - - - - - - -
- - -
-
-
-
-
-
- - - {fmt(ps.currentTime)} / {fmt(ps.duration)} - - -
- - - {#if ps.subtitleTracks.length > 0 || ps.burnableSubtitleTracks.length > 0} -
- - {#if ps.activePanel === 'subtitles'} - - {/if} -
- {/if} - - - {#if ps.audioTracks.length > 1} -
- - {#if ps.activePanel === 'audio'} - - {/if} -
- {/if} - - - {#if ps.levels.length > 0 || onqualitychange} -
- - {#if ps.activePanel === 'quality'} - - {/if} -
- {/if} - - - {#if !isAudio} - - {/if} -
-
-
- {/if} - - - {#if hasStarted && !ps.error && activeSkipMarker} - - {/if} - - - {#if hasStarted && !ps.error && postPlayVisible && nextItem} - { dismissPostPlay(); if (nextItem) onplaynext?.(nextItem); }} - ondismiss={() => { dismissPostPlay(); oncomplete?.(); }} - /> - {/if} -
- - diff --git a/src/lib/components/player/PostPlayCard.svelte b/src/lib/components/player/PostPlayCard.svelte deleted file mode 100644 index 8ea379a2..00000000 --- a/src/lib/components/player/PostPlayCard.svelte +++ /dev/null @@ -1,143 +0,0 @@ - - - - - - - - diff --git a/src/lib/components/player/QualityMenu.svelte b/src/lib/components/player/QualityMenu.svelte deleted file mode 100644 index c7e9c9a0..00000000 --- a/src/lib/components/player/QualityMenu.svelte +++ /dev/null @@ -1,176 +0,0 @@ - - -
-
Quality
- - - - {#each merged as row (row.kind === 'level' ? `l${row.level.index}` : `p${row.height}`)} - {#if row.kind === 'level'} - - {:else} - - {/if} - {/each} -
- - diff --git a/src/lib/components/player/SkipButton.svelte b/src/lib/components/player/SkipButton.svelte deleted file mode 100644 index 0d4af376..00000000 --- a/src/lib/components/player/SkipButton.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - - - - diff --git a/src/lib/components/player/SubtitleMenu.svelte b/src/lib/components/player/SubtitleMenu.svelte deleted file mode 100644 index d04e1b78..00000000 --- a/src/lib/components/player/SubtitleMenu.svelte +++ /dev/null @@ -1,148 +0,0 @@ - - -
-
Subtitles
- - - - {#each tracks as t (t.id)} - - {/each} - - {#if burnableTracks.length > 0} - - - {#if burnInExpanded} - {#each burnableTracks as t (t.id)} - - {/each} - {/if} - {/if} -
- - diff --git a/src/lib/components/player/engines/dash-engine.ts b/src/lib/components/player/engines/dash-engine.ts index 615be5c6..138a2a81 100644 --- a/src/lib/components/player/engines/dash-engine.ts +++ b/src/lib/components/player/engines/dash-engine.ts @@ -17,18 +17,28 @@ export async function createDashEngine(): Promise { videoEl = video; player = dashjs.MediaPlayer().create(); player.initialize(video, session.url, true); + // VOD ABR (researched): start LOW and ramp up (fast startup beats a + // high-quality stall), buffer-aware dynamic strategy, fast-switch so an + // up-shift replaces the buffered low-q segments with high-q immediately. + // `as any`: the installed dash.js types lag the runtime — ABRStrategy and + // stableBufferTime are valid settings at these paths but missing from the + // shipped .d.ts. Casting keeps the (tested) runtime config intact. player.updateSettings({ streaming: { abr: { + ABRStrategy: 'abrDynamic', autoSwitchBitrate: { video: true }, - // Start at the highest available quality; dash.js will - // step down automatically if bandwidth can't sustain it. - // Matches hls.js's abrEwmaDefaultEstimate high-start pattern. - initialBitrate: { video: 50_000_000, audio: 256_000 }, + initialBitrate: { video: 800, audio: 96 }, // kbps — start low, ramp + }, + buffer: { + fastSwitchEnabled: true, + stableBufferTime: 12, + bufferTimeAtTopQuality: 30, + bufferToKeep: 20, }, - buffer: { fastSwitchEnabled: true }, }, - }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); function refreshLevels() { try { diff --git a/src/lib/components/player/engines/hls-engine.ts b/src/lib/components/player/engines/hls-engine.ts index 460740c6..71629992 100644 --- a/src/lib/components/player/engines/hls-engine.ts +++ b/src/lib/components/player/engines/hls-engine.ts @@ -28,13 +28,18 @@ export async function createHlsEngine(): Promise { // `session.hlsConfig`, applied below. Keeping the base config // free of adapter quirks means Jellyfin, Invidious, and // direct-file HLS all use hls.js's well-tested defaults. + // VOD tuning (researched): start-low-then-ramp, deep buffer when the link + // allows, but a CAPPED back-buffer — hls.js defaults backBufferLength to + // Infinity, which leaks memory on long sessions (Mux QoE writeup). Seed the + // bandwidth estimate low (~1 Mbps) so the first pick isn't a blind over-shoot. const baseConfig = { - maxBufferLength: 60, - maxMaxBufferLength: 120, - startLevel: -1, - abrEwmaDefaultEstimate: 50_000_000, + startLevel: -1, // probe lowest first → fast start + a real bandwidth read + maxBufferLength: 30, // forward buffer baseline (s) + maxMaxBufferLength: 600, // allow a deep buffer when the link is fat + backBufferLength: 90, // CAP back-buffer (default Infinity = memory bloat) + abrEwmaDefaultEstimate: 1_000_000, // seed ~1 Mbps, not 50 enableWorker: true, - lowLatencyMode: false, + lowLatencyMode: false, // OFF for VOD debug: false, }; hls = new Hls({ diff --git a/src/lib/components/skeleton/SkeletonCard.svelte b/src/lib/components/skeleton/SkeletonCard.svelte deleted file mode 100644 index a15a114e..00000000 --- a/src/lib/components/skeleton/SkeletonCard.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - -
-
-
-
-
-
-
diff --git a/src/lib/components/skeleton/SkeletonDetail.svelte b/src/lib/components/skeleton/SkeletonDetail.svelte deleted file mode 100644 index 8851c67f..00000000 --- a/src/lib/components/skeleton/SkeletonDetail.svelte +++ /dev/null @@ -1,40 +0,0 @@ -
-
- -
-
-
- - -
- -
-
-
-
- -
- -
- -
-
-
-
-
- -
- {#each Array(6) as _} -
-
-
-
- {/each} -
- -
-
-
-
-
-
diff --git a/src/lib/components/skeleton/SkeletonGrid.svelte b/src/lib/components/skeleton/SkeletonGrid.svelte deleted file mode 100644 index 5c44ee46..00000000 --- a/src/lib/components/skeleton/SkeletonGrid.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - -
- {#each Array(count) as _} - - {/each} -
diff --git a/src/lib/components/skeleton/SkeletonHero.svelte b/src/lib/components/skeleton/SkeletonHero.svelte deleted file mode 100644 index 53323856..00000000 --- a/src/lib/components/skeleton/SkeletonHero.svelte +++ /dev/null @@ -1,26 +0,0 @@ -
- -
- -
- -
- -
-
-
-
-
-
- -
-
-
-
- -
-
-
diff --git a/src/lib/components/skeleton/SkeletonRow.svelte b/src/lib/components/skeleton/SkeletonRow.svelte deleted file mode 100644 index ddfbda70..00000000 --- a/src/lib/components/skeleton/SkeletonRow.svelte +++ /dev/null @@ -1,26 +0,0 @@ - - -
- -
- -
-
- - -
- {#each Array(count) as _, i} - - {/each} -
-
diff --git a/src/lib/components/skeleton/index.ts b/src/lib/components/skeleton/index.ts deleted file mode 100644 index 741dadc7..00000000 --- a/src/lib/components/skeleton/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { default as SkeletonCard } from './SkeletonCard.svelte'; -export { default as SkeletonRow } from './SkeletonRow.svelte'; -export { default as SkeletonGrid } from './SkeletonGrid.svelte'; -export { default as SkeletonHero } from './SkeletonHero.svelte'; -export { default as SkeletonDetail } from './SkeletonDetail.svelte'; diff --git a/src/lib/components/ui/BulkActionBar.svelte b/src/lib/components/ui/BulkActionBar.svelte deleted file mode 100644 index 1a4bec03..00000000 --- a/src/lib/components/ui/BulkActionBar.svelte +++ /dev/null @@ -1,52 +0,0 @@ - - -{#if selectedCount > 0} -
-
- - - - - - - - - - - -
-
-{/if} diff --git a/src/lib/components/ui/CategoryLinks.svelte b/src/lib/components/ui/CategoryLinks.svelte deleted file mode 100644 index f63f087e..00000000 --- a/src/lib/components/ui/CategoryLinks.svelte +++ /dev/null @@ -1,134 +0,0 @@ - - -
-
-
- -

- Browse Library -

-
- -
- -
- {#each categories as cat (cat.id)} - {@const Icon = iconMap[cat.id] ?? Film} - {@const accent = accentMap[cat.id] ?? defaultAccent} - - - {#if cat.image} - - {/if} - - - - - -
-
- -
- -
- -
-

- {cat.label} -

-

- {cat.count} {cat.count === 1 ? 'item' : 'items'} -

-
-
- {/each} -
-
diff --git a/src/lib/components/ui/SortSelect.svelte b/src/lib/components/ui/SortSelect.svelte deleted file mode 100644 index 2b2eec89..00000000 --- a/src/lib/components/ui/SortSelect.svelte +++ /dev/null @@ -1,82 +0,0 @@ - - - - -
- - - {#if open} -
- {#each options as option} - - {/each} -
- {/if} -
diff --git a/src/lib/components/ui/StatCard.svelte b/src/lib/components/ui/StatCard.svelte deleted file mode 100644 index a9f7c3ac..00000000 --- a/src/lib/components/ui/StatCard.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - - -
- {#if icon} - - {/if} -

- {value} -

-

{label}

- {#if sublabel} -

{sublabel}

- {/if} -
diff --git a/src/lib/components/video/ChannelCard.svelte b/src/lib/components/video/ChannelCard.svelte deleted file mode 100644 index 24549bbb..00000000 --- a/src/lib/components/video/ChannelCard.svelte +++ /dev/null @@ -1,152 +0,0 @@ - - -
- - {#if thumbnail} -
- {#if lowResSrc && !imgLoaded} - - {/if} - {author} (imgLoaded = true)} - /> -
- {:else} -
- {author.charAt(0).toUpperCase()} -
- {/if} -
- -
- - {author} - {#if authorVerified} - - {/if} - - {#if subCountText} -

{subCountText}

- {/if} -
- - {#if hasLinkedAccount} -
- {#if subscribed} - - {/if} - -
- {/if} -
diff --git a/src/lib/components/video/VideoCard.svelte b/src/lib/components/video/VideoCard.svelte deleted file mode 100644 index 6b5f6f8d..00000000 --- a/src/lib/components/video/VideoCard.svelte +++ /dev/null @@ -1,278 +0,0 @@ - - -{#if layout === 'list'} - -{:else} - -{/if} diff --git a/src/lib/components/video/VideoComments.svelte b/src/lib/components/video/VideoComments.svelte deleted file mode 100644 index ee223c2e..00000000 --- a/src/lib/components/video/VideoComments.svelte +++ /dev/null @@ -1,203 +0,0 @@ - - -
- {#if !loaded && !loading} - - {:else if loading && !loaded} - -
- {#each [1, 2, 3] as _} -
-
-
-
-
-
-
-
- {/each} -
- {:else} - -
-

{commentCount.toLocaleString()} Comments

-
- - -
-
- - -
- {#each comments as comment, i} -
- {#if getAuthorThumb(comment)} - {comment.author} - {:else} -
- {comment.author.charAt(0).toUpperCase()} -
- {/if} - -
-
- {comment.author} - {comment.publishedText} -
-

{comment.content}

-
- {#if comment.likeCount > 0} - - - {comment.likeCount.toLocaleString()} - - {/if} - {#if comment.replies && comment.replies.replyCount > 0 && !expandedReplies[i]} - - {/if} -
- - - {#if expandedReplies[i]} -
- {#each expandedReplies[i] as reply} -
- {#if getAuthorThumb(reply)} - {reply.author} - {:else} -
- {reply.author.charAt(0).toUpperCase()} -
- {/if} -
-
- {reply.author} - {reply.publishedText} -
-

{reply.content}

- {#if reply.likeCount > 0} - - - {reply.likeCount.toLocaleString()} - - {/if} -
-
- {/each} -
- {/if} -
-
- {/each} -
- - {#if loading} -
-
-
- {/if} - {/if} -
diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index d7367056..af50994f 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -213,6 +213,35 @@ function initDb(db: ReturnType) { created_at INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000) )`); + // ── Native media requests (the no-Overseerr path) ────────── + db.run(`CREATE TABLE IF NOT EXISTS media_requests ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + media_type TEXT NOT NULL, + tmdb_id INTEGER NOT NULL, + tvdb_id INTEGER, + title TEXT NOT NULL, + poster TEXT, + year INTEGER, + seasons TEXT, + status TEXT NOT NULL DEFAULT 'pending', + backend TEXT NOT NULL, + service_id TEXT NOT NULL, + source_request_id TEXT, + arr_service_id TEXT, + arr_item_id INTEGER, + quality_profile_id INTEGER, + root_folder_path TEXT, + approved_by TEXT REFERENCES users(id) ON DELETE SET NULL, + decline_reason TEXT, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000), + available_at INTEGER + )`); + db.run(`CREATE INDEX IF NOT EXISTS idx_media_requests_user ON media_requests(user_id)`); + db.run(`CREATE INDEX IF NOT EXISTS idx_media_requests_status ON media_requests(status)`); + db.run(`CREATE UNIQUE INDEX IF NOT EXISTS idx_media_requests_unique ON media_requests(user_id, tmdb_id, media_type)`); + // ── Migrations for existing databases ────────────────────── // ALTER TABLE is idempotent-safe: we catch "duplicate column" errors. const safeAddColumn = (table: string, col: string, typedef: string) => { @@ -248,6 +277,150 @@ function initDb(db: ReturnType) { safeAddColumn('users', 'force_password_reset', 'INTEGER NOT NULL DEFAULT 0'); safeAddColumn('users', 'status', "TEXT NOT NULL DEFAULT 'active'"); + // ── Better Auth (identity/session/RBAC/OIDC) ──────────────────────────── + // BA user fields on the existing users table (keeps all per-user FKs on users.id). + safeAddColumn('users', 'name', 'TEXT'); + safeAddColumn('users', 'display_username', 'TEXT'); + safeAddColumn('users', 'email', 'TEXT'); + safeAddColumn('users', 'email_verified', 'INTEGER NOT NULL DEFAULT 0'); + safeAddColumn('users', 'image', 'TEXT'); + safeAddColumn('users', 'role', 'TEXT'); + safeAddColumn('users', 'banned', 'INTEGER DEFAULT 0'); + safeAddColumn('users', 'ban_reason', 'TEXT'); + safeAddColumn('users', 'ban_expires', 'INTEGER'); + safeAddColumn('users', 'updated_at', 'INTEGER'); + // BA session table — DISTINCT from the legacy `sessions` (coexist during cutover). + db.run(`CREATE TABLE IF NOT EXISTS auth_sessions ( + id TEXT PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL, + ip_address TEXT, + user_agent TEXT, + impersonated_by TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`); + db.run(`CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id)`); + db.run(`CREATE TABLE IF NOT EXISTS accounts ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + access_token TEXT, refresh_token TEXT, id_token TEXT, + access_token_expires_at INTEGER, refresh_token_expires_at INTEGER, + scope TEXT, password TEXT, + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL + )`); + db.run(`CREATE INDEX IF NOT EXISTS idx_accounts_user ON accounts(user_id)`); + db.run(`CREATE TABLE IF NOT EXISTS verifications ( + id TEXT PRIMARY KEY, + identifier TEXT NOT NULL, + value TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER, updated_at INTEGER + )`); + + // ── Better Auth users-table rebuild (idempotent) ───────────────────────── + // The Better Auth drizzle adapter writes Date objects into users.created_at, + // but the legacy column is declared TEXT (TEXT affinity → can't hold an + // integer/Date; safeConvertTimestamp can't fix affinity, only contents). It + // also needs display_name/password_hash to be NULLable (OIDC/credential users + // supply neither — the password lives in `accounts`). SQLite can't ALTER a + // column's type or drop NOT NULL, so rebuild the table once. Guarded on the + // declared type of created_at, so this runs exactly once per database (and + // converges fresh installs, whose migrate()-created users table is also TEXT). + try { + const createdAtType = ( + _sqlite!.prepare(`SELECT type FROM pragma_table_info('users') WHERE name='created_at'`).get() as + | { type?: string } + | undefined + )?.type; + if (createdAtType && createdAtType.toUpperCase().includes('TEXT')) { + // PRAGMA foreign_keys can't change inside a transaction, so toggle it + // around the txn. initDb runs synchronously to completion on the single + // shared connection before getDb() returns, so no other query runs in + // this window. The `finally` guarantees FKs are re-enabled even on throw. + _sqlite!.pragma('foreign_keys = OFF'); + try { + const rebuild = _sqlite!.transaction(() => { + _sqlite!.exec(` + DROP TABLE IF EXISTS users_ba_new; + CREATE TABLE users_ba_new ( + id TEXT PRIMARY KEY NOT NULL, + username TEXT NOT NULL, + display_name TEXT, + password_hash TEXT, + is_admin INTEGER NOT NULL DEFAULT 0, + auth_provider TEXT NOT NULL DEFAULT 'local', + external_id TEXT, + avatar TEXT, + force_password_reset INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + welcome_completed_at TEXT, + name TEXT, + display_username TEXT, + email TEXT, + email_verified INTEGER NOT NULL DEFAULT 0, + image TEXT, + role TEXT, + banned INTEGER DEFAULT 0, + ban_reason TEXT, + ban_expires INTEGER, + updated_at INTEGER, + created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER) * 1000) + ); + INSERT INTO users_ba_new ( + id, username, display_name, password_hash, is_admin, auth_provider, + external_id, avatar, force_password_reset, status, welcome_completed_at, + name, display_username, email, email_verified, image, role, banned, + ban_reason, ban_expires, updated_at, created_at + ) + SELECT + id, username, display_name, password_hash, is_admin, auth_provider, + external_id, avatar, force_password_reset, status, welcome_completed_at, + name, display_username, email, email_verified, image, role, banned, + ban_reason, ban_expires, updated_at, + -- NULLIF guards against strftime() returning 0 for a malformed + -- date string (which would silently become 1970); fall through + -- to an already-integer ms value, then to now(). + COALESCE(NULLIF(CAST(strftime('%s', created_at) AS INTEGER), 0) * 1000, CAST(created_at AS INTEGER), CAST(strftime('%s','now') AS INTEGER) * 1000) + FROM users; + DROP TABLE users; + ALTER TABLE users_ba_new RENAME TO users; + CREATE UNIQUE INDEX users_username_unique ON users(username); + `); + }); + rebuild(); + // Verify the FK graph re-bound correctly to the renamed table. + const fkViolations = _sqlite!.pragma('foreign_key_check') as unknown[]; + if (Array.isArray(fkViolations) && fkViolations.length > 0) { + console.error('[db] FK violations after users rebuild:', fkViolations); + } + // Email unique index OUTSIDE the rebuild transaction: if pre-existing + // duplicate (non-null) emails exist it would throw, and inside the txn + // that would roll back the whole conversion → the guard stays TEXT → + // every boot re-attempts and re-fails (perpetual broken boot). Out here, + // a failure only means the index is missing (logged), not a broken table. + try { + _sqlite!.exec('CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique ON users(email)'); + } catch (e) { + console.error( + '[db] could not create users_email_unique (duplicate emails?):', + e instanceof Error ? e.message : e + ); + } + console.log('[db] Rebuilt users table for Better Auth (created_at → integer ms, nullable display_name/password_hash)'); + } catch (e) { + console.error('[db] users Better Auth rebuild failed:', e instanceof Error ? e.message : e); + } finally { + _sqlite!.pragma('foreign_keys = ON'); + } + } + } catch (e) { + console.error('[db] users Better Auth rebuild guard failed:', e instanceof Error ? e.message : e); + } + // Legacy `activity` table dropped 2026-04-17 (migration 0008). The // user_id / position columns we used to ALTER onto it are now native on // play_sessions, which is the canonical progress store. @@ -284,6 +457,50 @@ function initDb(db: ReturnType) { db.run(`CREATE INDEX IF NOT EXISTS idx_ps_user_type ON play_sessions(user_id, media_type)`); db.run(`CREATE INDEX IF NOT EXISTS idx_ps_active ON play_sessions(ended_at) WHERE ended_at IS NULL`); + // ── Playlists (Nexus-native, mutable, cross-backend) ─────────── + // Items are denormalized (backend + source_id + title/poster/type) so a + // playlist mixing Jellyfin + Invidious renders without re-hitting each + // backend. This is the first REAL user data in the Nexus DB — deploys must + // NOT wipe the volume (no `down -v`). + db.run(`CREATE TABLE IF NOT EXISTS playlists ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000) + )`); + db.run(`CREATE INDEX IF NOT EXISTS idx_pl_user ON playlists(user_id, updated_at)`); + db.run(`CREATE TABLE IF NOT EXISTS playlist_items ( + id TEXT PRIMARY KEY, + playlist_id TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + backend TEXT NOT NULL, + source_id TEXT NOT NULL, + type TEXT, + title TEXT NOT NULL, + poster TEXT, + year INTEGER, + added_at INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000) + )`); + db.run(`CREATE INDEX IF NOT EXISTS idx_pli_playlist ON playlist_items(playlist_id, position)`); + db.run(`CREATE UNIQUE INDEX IF NOT EXISTS idx_pli_dedupe ON playlist_items(playlist_id, backend, source_id)`); + + // ── Annotations / post-it "stickies" (ported from tasks) ─────── + // Page-anchored collaborative notes for design critique. Anchored to a DOM + // element by CSS selector + relative offset so the pin tracks on scroll/resize. + db.run(`CREATE TABLE IF NOT EXISTS annotations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_path TEXT NOT NULL, + anchor_selector TEXT NOT NULL, + anchor_snippet TEXT DEFAULT '', + anchor_offset_x REAL DEFAULT 0.5, + anchor_offset_y REAL DEFAULT 0.5, + body TEXT NOT NULL, + author TEXT DEFAULT '', + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000) + )`); + db.run(`CREATE INDEX IF NOT EXISTS idx_ann_page ON annotations(page_path, created_at)`); + // ── Media Actions ────────────────────────────────────────────── db.run(`CREATE TABLE IF NOT EXISTS media_actions ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 83868eb9..25293510 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -163,26 +163,84 @@ export const requests = sqliteTable('requests', { .default(sql`(datetime('now'))`) }); +// Native media requests (the "no-Overseerr" path). Coexists with the legacy +// `requests` table (which has no writers) and with the Overseerr proxy path. +// A row is created when a user requests a movie/show; an admin approves it, +// at which point it's pushed into Radarr/Sonarr for download. `backend` +// distinguishes native rows from Overseerr-proxied rows that may also be +// mirrored here. JSON-in-text for `seasons` (array of season numbers). +export const mediaRequests = sqliteTable('media_requests', { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + mediaType: text('media_type').notNull(), // 'movie' | 'tv' + tmdbId: integer('tmdb_id').notNull(), + tvdbId: integer('tvdb_id'), + title: text('title').notNull(), + poster: text('poster'), + year: integer('year'), + seasons: text('seasons'), // JSON array of season numbers (TV only) + status: text('status').notNull().default('pending'), // 'pending' | 'approved' | 'declined' | 'processing' | 'available' | 'failed' + backend: text('backend').notNull(), // 'overseerr' | 'native' + serviceId: text('service_id').notNull(), + sourceRequestId: text('source_request_id'), // Overseerr request id when backend='overseerr' + arrServiceId: text('arr_service_id'), // the radarr/sonarr service the item was added to + arrItemId: integer('arr_item_id'), // radarr movieId / sonarr seriesId + qualityProfileId: integer('quality_profile_id'), + rootFolderPath: text('root_folder_path'), + approvedBy: text('approved_by').references(() => users.id, { onDelete: 'set null' }), + declineReason: text('decline_reason'), + createdAt: integer('created_at') + .notNull() + .default(sql`(strftime('%s','now') * 1000)`), + updatedAt: integer('updated_at') + .notNull() + .default(sql`(strftime('%s','now') * 1000)`), + availableAt: integer('available_at') +}, (table) => [ + index('idx_media_requests_user').on(table.userId), + index('idx_media_requests_status').on(table.status), + uniqueIndex('idx_media_requests_unique').on(table.userId, table.tmdbId, table.mediaType), +]); + +export type MediaRequest = typeof mediaRequests.$inferSelect; +export type NewMediaRequest = typeof mediaRequests.$inferInsert; + // Nexus users — the app's own user accounts export const users = sqliteTable('users', { id: text('id').primaryKey(), // UUID username: text('username').notNull().unique(), - displayName: text('display_name').notNull(), - passwordHash: text('password_hash').notNull(), + // Legacy columns: nullable now that Better Auth can create users (OIDC users + // won't set displayName/passwordHash — BA stores the password in `accounts`). + displayName: text('display_name'), + passwordHash: text('password_hash'), isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false), authProvider: text('auth_provider').notNull().default('local'), // 'local' | 'jellyfin' externalId: text('external_id'), // e.g. Jellyfin userId for migrated users avatar: text('avatar'), // URL or path to profile picture forcePasswordReset: integer('force_password_reset', { mode: 'boolean' }).notNull().default(false), status: text('status').notNull().default('active'), // 'active' | 'pending' - // Per-user onboarding flag for the /welcome flow. Null means "never seen - // the welcome flow yet"; set to an ISO timestamp when the user completes - // or skips the /welcome wizard. Non-admin users with null welcomeCompletedAt - // are redirected to /welcome on first login. welcomeCompletedAt: text('welcome_completed_at'), - createdAt: text('created_at') + // ── Better Auth user fields (the BA drizzle adapter reads/writes these) ── + name: text('name'), // BA display name (mirrors displayName during transition) + displayUsername: text('display_username'), // BA username plugin + email: text('email').unique(), + emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false), + image: text('image'), + role: text('role'), // BA admin plugin RBAC (replaces the isAdmin boolean over time) + banned: integer('banned', { mode: 'boolean' }).default(false), + banReason: text('ban_reason'), + banExpires: integer('ban_expires', { mode: 'timestamp_ms' }), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }), + // Stored as unix-ms integer (timestamp_ms): the Better Auth drizzle adapter + // writes Date objects here, and a TEXT-affinity column can't hold them. The + // users-table rebuild migration converts the legacy datetime() text values to + // ms. (Other date columns BA touches — updatedAt, accounts/sessions — are + // already integer ms.) + createdAt: integer('created_at', { mode: 'timestamp_ms' }) .notNull() - .default(sql`(datetime('now'))`) + .default(sql`(CAST(strftime('%s','now') AS INTEGER) * 1000)`) }); // Auth sessions @@ -198,6 +256,52 @@ export const sessions = sqliteTable('sessions', { index('idx_sessions_user').on(table.userId), ]); +// ── Better Auth tables ────────────────────────────────────────────────────── +// BA owns identity/session/RBAC/OIDC. Its session table is kept DISTINCT from the +// legacy `sessions` (above) so the two coexist during the cutover; the BA config +// maps model→table explicitly. `accounts` holds the password (moved off users) +// + OIDC tokens; `verifications` backs email/OIDC flows. +export const authSessions = sqliteTable('auth_sessions', { + id: text('id').primaryKey(), + token: text('token').notNull().unique(), + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + impersonatedBy: text('impersonated_by'), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull() +}, (table) => [index('idx_auth_sessions_user').on(table.userId)]); + +export const accounts = sqliteTable('accounts', { + id: text('id').primaryKey(), + accountId: text('account_id').notNull(), + providerId: text('provider_id').notNull(), + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + idToken: text('id_token'), + accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp_ms' }), + refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp_ms' }), + scope: text('scope'), + password: text('password'), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull() +}, (table) => [index('idx_accounts_user').on(table.userId)]); + +export const verifications = sqliteTable('verifications', { + id: text('id').primaryKey(), + identifier: text('identifier').notNull(), + value: text('value').notNull(), + expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp_ms' }), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }) +}); + // ── Play Sessions (canonical progress/temporal truth for every media type) ── // // Since 2026-04-17 this table is the ONLY write target for progress across diff --git a/src/lib/server/__tests__/auth-credentials-encryption.test.ts b/src/lib/server/__tests__/auth-credentials-encryption.test.ts deleted file mode 100644 index 45f0e007..00000000 --- a/src/lib/server/__tests__/auth-credentials-encryption.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -// Point the DB at a fresh tmp file BEFORE importing any DB modules so we don't -// stomp the dev DB. -const testDbDir = mkdtempSync(join(tmpdir(), 'nexus-auth-enc-')); -process.env.DATABASE_URL = join(testDbDir, 'test.db'); -process.env.NEXUS_ENCRYPTION_KEY = 'a'.repeat(64); - -// Import lazily so env is set first. -let upsertUserCredential: typeof import('../auth').upsertUserCredential; -let getDb: typeof import('../../db').getDb; -let schema: typeof import('../../db').schema; - -describe('upsertUserCredential encrypts stored_password at rest', () => { - beforeAll(async () => { - const authMod = await import('../auth'); - upsertUserCredential = authMod.upsertUserCredential; - const dbMod = await import('../../db'); - getDb = dbMod.getDb; - schema = dbMod.schema; - - // Run the drizzle migrations so the schema exists. - const { migrate } = await import('drizzle-orm/better-sqlite3/migrator'); - migrate(getDb(), { migrationsFolder: 'drizzle' }); - - // Seed a user + service so FK constraints (if any) don't block the test. - const db = getDb(); - db.insert(schema.users) - .values({ - id: 'u-enc', - username: 'enc', - displayName: 'Enc', - passwordHash: 'x', - isAdmin: false, - authProvider: 'local', - status: 'active', - forcePasswordReset: false - }) - .run(); - db.insert(schema.services) - .values({ - id: 's-enc', - type: 'jellyfin', - name: 'Test', - url: 'http://localhost', - enabled: true - }) - .run(); - }); - - beforeEach(() => { - const db = getDb(); - db.delete(schema.userServiceCredentials).run(); - }); - - it('persists encrypted ciphertext, not plaintext', () => { - upsertUserCredential('u-enc', 's-enc', { - accessToken: 'tok', - storedPassword: 'hunter2' - }, { skipDerivedLink: true }); - - const db = getDb(); - const row = db.select().from(schema.userServiceCredentials).get(); - expect(row).toBeTruthy(); - expect(row!.storedPassword).not.toBe('hunter2'); - expect(row!.storedPassword!.startsWith('v1:')).toBe(true); - }); - - it('produces a different envelope each call (random IV)', () => { - upsertUserCredential('u-enc', 's-enc', { storedPassword: 'same' }, { skipDerivedLink: true }); - const db = getDb(); - const first = db.select().from(schema.userServiceCredentials).get()!.storedPassword!; - db.delete(schema.userServiceCredentials).run(); - upsertUserCredential('u-enc', 's-enc', { storedPassword: 'same' }, { skipDerivedLink: true }); - const second = db.select().from(schema.userServiceCredentials).get()!.storedPassword!; - expect(first).not.toBe(second); - }); - - it('null storedPassword passes through without encryption', () => { - upsertUserCredential('u-enc', 's-enc', { - accessToken: 'tok', - storedPassword: null - }, { skipDerivedLink: true }); - const db = getDb(); - const row = db.select().from(schema.userServiceCredentials).get(); - expect(row!.storedPassword).toBeNull(); - }); -}); - -// cleanup — best-effort. -process.on('exit', () => { - try { - rmSync(testDbDir, { recursive: true, force: true }); - } catch { - // ignore - } -}); diff --git a/src/lib/server/__tests__/cache.test.ts b/src/lib/server/__tests__/cache.test.ts deleted file mode 100644 index 890964d9..00000000 --- a/src/lib/server/__tests__/cache.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { - withCache, - withStaleCache, - invalidate, - invalidatePrefix, - invalidateAll, - __debugCacheStats, - __debugCacheKeys -} from '../cache'; - -describe('withCache', () => { - beforeEach(() => { - invalidatePrefix('test:'); - }); - - it('caches the result of fn for the TTL duration', async () => { - let callCount = 0; - const fn = async () => { - callCount++; - return 'value'; - }; - - const first = await withCache('test:a', 5000, fn); - const second = await withCache('test:a', 5000, fn); - - expect(first).toBe('value'); - expect(second).toBe('value'); - expect(callCount).toBe(1); - }); - - it('re-fetches after invalidation', async () => { - let callCount = 0; - const fn = async () => { - callCount++; - return `call-${callCount}`; - }; - - await withCache('test:b', 5000, fn); - invalidate('test:b'); - const result = await withCache('test:b', 5000, fn); - - expect(result).toBe('call-2'); - expect(callCount).toBe(2); - }); - - it('invalidatePrefix clears all matching keys', async () => { - let countA = 0, - countB = 0; - await withCache('test:x:1', 5000, async () => ++countA); - await withCache('test:x:2', 5000, async () => ++countB); - - invalidatePrefix('test:x:'); - - await withCache('test:x:1', 5000, async () => ++countA); - await withCache('test:x:2', 5000, async () => ++countB); - - expect(countA).toBe(2); - expect(countB).toBe(2); - }); - - it('respects TTL expiry', async () => { - let n = 0; - await withCache('test:ttl', 10, async () => ++n); - await new Promise((r) => setTimeout(r, 30)); - await withCache('test:ttl', 10, async () => ++n); - expect(n).toBe(2); - }); -}); - -describe('withStaleCache', () => { - beforeEach(() => { - invalidatePrefix('test:'); - }); - - it('returns stale data immediately and refreshes in background', async () => { - let n = 0; - const fn = async () => ++n; - await withStaleCache('test:s', 10, 5_000, fn); - await new Promise((r) => setTimeout(r, 30)); // now stale, still within grace - const result = await withStaleCache('test:s', 10, 5_000, fn); - expect(result).toBe(1); // served stale - // Let the background refresh settle. - await new Promise((r) => setTimeout(r, 20)); - const fresh = await withStaleCache('test:s', 10_000, 5_000, fn); - expect(fresh).toBe(2); - }); -}); - -describe('cache debug hooks', () => { - beforeEach(() => { - invalidateAll(); - }); - - it('records hits and misses', async () => { - await withCache('test:stats', 5000, async () => 'x'); - await withCache('test:stats', 5000, async () => 'x'); - const stats = __debugCacheStats(); - expect(stats.hits).toBeGreaterThanOrEqual(1); - expect(stats.misses).toBeGreaterThanOrEqual(1); - expect(stats.size).toBeGreaterThanOrEqual(1); - }); - - it('exposes keys under NODE_ENV=test', async () => { - const prev = process.env.NODE_ENV; - process.env.NODE_ENV = 'test'; - await withCache('test:keyA', 5000, async () => 1); - await withCache('test:keyB', 5000, async () => 1); - const keys = __debugCacheKeys(); - expect(keys).toContain('test:keyA'); - expect(keys).toContain('test:keyB'); - process.env.NODE_ENV = prev; - }); -}); diff --git a/src/lib/server/__tests__/continue-watching.test.ts b/src/lib/server/__tests__/continue-watching.test.ts deleted file mode 100644 index 9850d099..00000000 --- a/src/lib/server/__tests__/continue-watching.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import Database from 'better-sqlite3'; -import { drizzle } from 'drizzle-orm/better-sqlite3'; - -let testDb: InstanceType; - -vi.mock('$lib/db', async () => { - const actual = await vi.importActual('$lib/db'); - return { - ...actual, - getDb: () => drizzle(testDb, { schema: actual.schema }), - getRawDb: () => testDb, - }; -}); - -vi.mock('../auth', () => ({ - getUserCredentialForService: () => undefined, -})); - -// Stub adapter registry: fake adapter returns UnifiedMedia from its mediaId. -vi.mock('$lib/adapters/registry', () => { - const adapter = { - getItem: async (cfg: any, sourceId: string) => ({ - sourceId, - serviceId: cfg.id, - type: 'movie', - title: `Item ${sourceId}`, - }), - userLinkable: false, - }; - return { - registry: { - get: () => adapter, - }, - }; -}); - -import { getContinueWatching } from '../continue-watching'; - -const USER = 'user-fixture'; -const SERVICE = { id: 'svc-1', type: 'jellyfin', name: 'jf', url: '', enabled: true } as any; - -function createSchema(db: InstanceType) { - db.exec(` - CREATE TABLE play_sessions ( - id TEXT PRIMARY KEY, - session_key TEXT, - user_id TEXT NOT NULL, - service_id TEXT NOT NULL, - service_type TEXT NOT NULL, - media_id TEXT NOT NULL, - media_type TEXT NOT NULL, - media_title TEXT, - media_year INTEGER, - media_genres TEXT, - parent_id TEXT, - parent_title TEXT, - started_at INTEGER NOT NULL, - ended_at INTEGER, - duration_ms INTEGER DEFAULT 0, - media_duration_ms INTEGER, - progress REAL, - completed INTEGER DEFAULT 0, - position TEXT, - position_ticks INTEGER, - device_name TEXT, - client_name TEXT, - metadata TEXT, - source TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - `); -} - -function insert( - db: InstanceType, - id: string, - mediaId: string, - progress: number | null, - completed: 0 | 1, - updatedAt: number -) { - db.prepare( - `INSERT INTO play_sessions ( - id, user_id, service_id, service_type, media_id, media_type, - progress, completed, source, created_at, updated_at, started_at - ) VALUES (?, ?, 'svc-1', 'jellyfin', ?, 'movie', ?, ?, 'test', ?, ?, ?)` - ).run(id, USER, mediaId, progress, completed, updatedAt, updatedAt, updatedAt); -} - -describe('getContinueWatching', () => { - beforeEach(() => { - testDb = new Database(':memory:'); - createSchema(testDb); - }); - - it('returns items ordered by updated_at DESC', async () => { - insert(testDb, 's1', 'media-a', 0.3, 0, 1000); - insert(testDb, 's2', 'media-b', 0.5, 0, 3000); - insert(testDb, 's3', 'media-c', 0.4, 0, 2000); - - const items = await getContinueWatching(USER, { configs: [SERVICE] }); - expect(items.map((i) => i.sourceId)).toEqual(['media-b', 'media-c', 'media-a']); - }); - - it('excludes completed rows', async () => { - insert(testDb, 's1', 'media-a', 0.95, 1, 1000); - insert(testDb, 's2', 'media-b', 0.5, 0, 2000); - - const items = await getContinueWatching(USER, { configs: [SERVICE] }); - expect(items.map((i) => i.sourceId)).toEqual(['media-b']); - }); - - it('excludes barely-started rows (progress <= 0.02)', async () => { - insert(testDb, 's1', 'media-a', 0.01, 0, 1000); - insert(testDb, 's2', 'media-b', 0.5, 0, 2000); - - const items = await getContinueWatching(USER, { configs: [SERVICE] }); - expect(items.map((i) => i.sourceId)).toEqual(['media-b']); - }); - - it('excludes near-done rows (progress >= 0.9)', async () => { - insert(testDb, 's1', 'media-a', 0.95, 0, 1000); - insert(testDb, 's2', 'media-b', 0.5, 0, 2000); - - const items = await getContinueWatching(USER, { configs: [SERVICE] }); - expect(items.map((i) => i.sourceId)).toEqual(['media-b']); - }); - - it('excludes null progress', async () => { - insert(testDb, 's1', 'media-a', null, 0, 1000); - insert(testDb, 's2', 'media-b', 0.5, 0, 2000); - - const items = await getContinueWatching(USER, { configs: [SERVICE] }); - expect(items.map((i) => i.sourceId)).toEqual(['media-b']); - }); - - it('stamps canonical progress from play_sessions on resolved items', async () => { - insert(testDb, 's1', 'media-a', 0.42, 0, 1000); - const items = await getContinueWatching(USER, { configs: [SERVICE] }); - expect(items[0].progress).toBe(0.42); - }); - - it('dedupes by (serviceId, mediaId), keeping most recent', async () => { - insert(testDb, 's1', 'media-a', 0.3, 0, 1000); - insert(testDb, 's2', 'media-a', 0.7, 0, 2000); - insert(testDb, 's3', 'media-b', 0.5, 0, 1500); - - const items = await getContinueWatching(USER, { configs: [SERVICE] }); - expect(items.map((i) => i.sourceId)).toEqual(['media-a', 'media-b']); - expect(items[0].progress).toBe(0.7); - }); - - it('honors the limit option', async () => { - for (let i = 0; i < 10; i++) { - insert(testDb, `s${i}`, `media-${i}`, 0.5, 0, 1000 + i); - } - const items = await getContinueWatching(USER, { configs: [SERVICE], limit: 3 }); - expect(items).toHaveLength(3); - }); -}); diff --git a/src/lib/server/__tests__/crypto.test.ts b/src/lib/server/__tests__/crypto.test.ts deleted file mode 100644 index 9814a248..00000000 --- a/src/lib/server/__tests__/crypto.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; -import { - encryptAtRest, - decryptAtRest, - encryptStoredPassword, - decryptStoredPassword, - assertEncryptionKey, - __resetCryptoForTests -} from '../crypto'; - -// 32 bytes hex (random-looking but fixed for determinism). -const TEST_KEY = 'a'.repeat(64); - -describe('crypto (AES-256-GCM envelope)', () => { - beforeAll(() => { - process.env.NEXUS_ENCRYPTION_KEY = TEST_KEY; - }); - - beforeEach(() => { - __resetCryptoForTests(); - }); - - it('assertEncryptionKey passes with a valid hex key', () => { - expect(() => assertEncryptionKey()).not.toThrow(); - }); - - it('auto-generates and persists a key when the env var is missing', () => { - // Behavior change (codex round 8 followup): missing env is no longer a - // hard-fail. Nexus auto-generates a key to `/.nexus-encryption-key` - // on first boot so docker-compose-up still works as a one-line deploy. - const saved = process.env.NEXUS_ENCRYPTION_KEY; - const savedDb = process.env.DATABASE_URL; - delete process.env.NEXUS_ENCRYPTION_KEY; - // Point DATABASE_URL at a tmp dir so the auto-gen writes somewhere safe. - const { mkdtempSync, existsSync, rmSync } = require('node:fs') as typeof import('node:fs'); - const { tmpdir } = require('node:os') as typeof import('node:os'); - const { resolve } = require('node:path') as typeof import('node:path'); - const dir = mkdtempSync(resolve(tmpdir(), 'nexus-crypto-test-')); - process.env.DATABASE_URL = resolve(dir, 'test.db'); - __resetCryptoForTests(); - try { - expect(() => assertEncryptionKey()).not.toThrow(); - expect(existsSync(resolve(dir, '.nexus-encryption-key'))).toBe(true); - } finally { - process.env.NEXUS_ENCRYPTION_KEY = saved; - if (savedDb === undefined) delete process.env.DATABASE_URL; - else process.env.DATABASE_URL = savedDb; - rmSync(dir, { recursive: true, force: true }); - __resetCryptoForTests(); - } - }); - - it('round-trips a plaintext', () => { - const blob = encryptAtRest('hunter2'); - expect(blob.startsWith('v1:')).toBe(true); - expect(blob).not.toContain('hunter2'); - expect(decryptAtRest(blob)).toBe('hunter2'); - }); - - it('produces a different envelope per call (random IV)', () => { - const a = encryptAtRest('same'); - const b = encryptAtRest('same'); - expect(a).not.toBe(b); - expect(decryptAtRest(a)).toBe('same'); - expect(decryptAtRest(b)).toBe('same'); - }); - - it('null passes through both paths', () => { - expect(decryptAtRest(null)).toBeNull(); - expect(decryptStoredPassword(null)).toBeNull(); - expect(encryptStoredPassword(null)).toBeNull(); - }); - - it('undefined passes through the stored-password helper', () => { - expect(encryptStoredPassword(undefined)).toBeUndefined(); - expect(decryptStoredPassword(undefined)).toBeNull(); - }); - - it('rejects an unknown version prefix', () => { - expect(() => decryptAtRest('v99:aa:bb:cc')).toThrow(/unsupported version/); - }); - - it('rejects a malformed envelope', () => { - expect(() => decryptAtRest('nope')).toThrow(/malformed envelope/); - }); - - it('detects tampering via the GCM auth tag', () => { - const blob = encryptAtRest('secret'); - const [version, iv, tag, ctRaw] = blob.split(':'); - // Flip the last byte of the ciphertext. - const ctBuf = Buffer.from(ctRaw, 'base64'); - ctBuf[ctBuf.length - 1] ^= 0xff; - const tampered = `${version}:${iv}:${tag}:${ctBuf.toString('base64')}`; - expect(() => decryptAtRest(tampered)).toThrow(); - }); -}); diff --git a/src/lib/server/__tests__/homepage-cache.test.ts b/src/lib/server/__tests__/homepage-cache.test.ts deleted file mode 100644 index 86f3559b..00000000 --- a/src/lib/server/__tests__/homepage-cache.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { applyRowOrder, DEFAULT_ROW_ORDER } from '../homepage-cache'; -import type { HomepageRow } from '$lib/types/homepage'; - -function row(id: string, type: HomepageRow['type'] = 'system'): HomepageRow { - return { id, title: id, type, items: [] }; -} - -describe('applyRowOrder — drift pin', () => { - it('orders rows per DEFAULT_ROW_ORDER when no user order is given', () => { - const rows = [row('new'), row('trending-movie', 'reason'), row('calendar', 'calendar')]; - const result = applyRowOrder(rows); - expect(result.map((r) => r.id)).toEqual(['calendar', 'trending-movie', 'new']); - }); - - it('honors user rowOrder verbatim', () => { - const rows = [row('trending-movie', 'reason'), row('new'), row('calendar', 'calendar')]; - const result = applyRowOrder(rows, ['new', 'calendar', 'trending-movie']); - expect(result.map((r) => r.id)).toEqual(['new', 'calendar', 'trending-movie']); - }); - - it('appends rows not listed in the order at the end', () => { - const rows = [row('mystery-new-row'), row('trending-movie', 'reason')]; - const result = applyRowOrder(rows); - // trending-movie matches DEFAULT, mystery-new-row falls through to the tail. - expect(result.map((r) => r.id)).toEqual(['trending-movie', 'mystery-new-row']); - }); - - it('expands genre:* to every genre row in the order the rows arrive', () => { - const rows = [ - row('genre:sci-fi', 'genre'), - row('genre:drama', 'genre'), - row('trending-movie', 'reason') - ]; - const result = applyRowOrder(rows); - // DEFAULT_ROW_ORDER places trending-movie before genre:* - const ids = result.map((r) => r.id); - expect(ids.indexOf('trending-movie')).toBeLessThan(ids.indexOf('genre:sci-fi')); - expect(ids).toContain('genre:drama'); - }); - - it('does not duplicate a row when it appears in the order and a genre expansion', () => { - const rows = [row('genre:drama', 'genre')]; - const result = applyRowOrder(rows, ['genre:drama', 'genre:*']); - expect(result.filter((r) => r.id === 'genre:drama')).toHaveLength(1); - }); - - it('treats calendar + upcoming-* + suggestions as first-class orderable rows', () => { - // If any of these fall out of DEFAULT_ROW_ORDER we lose the ordering - // contract; this test is the canary for future drift. - for (const id of ['calendar', 'upcoming-movies', 'upcoming-tv', 'suggestions']) { - expect(DEFAULT_ROW_ORDER).toContain(id); - } - }); -}); diff --git a/src/lib/server/__tests__/rate-limit.test.ts b/src/lib/server/__tests__/rate-limit.test.ts deleted file mode 100644 index 40510eb2..00000000 --- a/src/lib/server/__tests__/rate-limit.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import type { RequestEvent } from '@sveltejs/kit'; -import { - checkRateLimit, - resetRateLimiter, - getClientIp, - __resetRateLimitConfig -} from '../rate-limit'; - -describe('checkRateLimit', () => { - beforeEach(() => { - resetRateLimiter(); - }); - - it('allows requests under the limit', () => { - for (let i = 0; i < 10; i++) { - expect(checkRateLimit('1.2.3.4', 10, 60000)).toBe(true); - } - }); - - it('blocks requests over the limit', () => { - for (let i = 0; i < 10; i++) { - checkRateLimit('1.2.3.5', 10, 60000); - } - expect(checkRateLimit('1.2.3.5', 10, 60000)).toBe(false); - }); - - it('tracks IPs independently', () => { - for (let i = 0; i < 10; i++) { - checkRateLimit('1.1.1.1', 10, 60000); - } - expect(checkRateLimit('1.1.1.1', 10, 60000)).toBe(false); - expect(checkRateLimit('2.2.2.2', 10, 60000)).toBe(true); - }); - - it('resets after window expires', () => { - const shortWindow = 50; // 50ms - for (let i = 0; i < 5; i++) { - checkRateLimit('3.3.3.3', 5, shortWindow); - } - expect(checkRateLimit('3.3.3.3', 5, shortWindow)).toBe(false); - - return new Promise((resolve) => { - setTimeout(() => { - expect(checkRateLimit('3.3.3.3', 5, shortWindow)).toBe(true); - resolve(); - }, 60); - }); - }); -}); - -function fakeEvent(peer: string, xff?: string): RequestEvent { - return { - getClientAddress: () => peer, - request: new Request('http://localhost/', { - headers: xff ? { 'x-forwarded-for': xff } : {} - }) - } as unknown as RequestEvent; -} - -describe('getClientIp (trusted-proxy handling)', () => { - beforeEach(() => { - delete process.env.NEXUS_TRUST_PROXY; - delete process.env.NEXUS_TRUSTED_PROXIES; - __resetRateLimitConfig(); - }); - - it('returns the peer address when NEXUS_TRUST_PROXY is off, ignoring XFF', () => { - const ip = getClientIp(fakeEvent('1.2.3.4', '9.9.9.9, 5.5.5.5')); - expect(ip).toBe('1.2.3.4'); - }); - - it('honors XFF when trust is on AND peer is in default RFC1918 range', () => { - process.env.NEXUS_TRUST_PROXY = '1'; - __resetRateLimitConfig(); - // Peer is 10.x (a default trusted proxy); XFF leftmost is the real client. - const ip = getClientIp(fakeEvent('10.0.0.5', '9.9.9.9')); - expect(ip).toBe('9.9.9.9'); - }); - - it('walks XFF right-to-left past trusted hops', () => { - process.env.NEXUS_TRUST_PROXY = '1'; - __resetRateLimitConfig(); - // Real client is 8.8.8.8, then two trusted hops. - const ip = getClientIp(fakeEvent('10.0.0.5', '8.8.8.8, 10.0.0.6, 10.0.0.5')); - expect(ip).toBe('8.8.8.8'); - }); - - it('falls back to peer when trust is on but peer is NOT a trusted proxy', () => { - process.env.NEXUS_TRUST_PROXY = '1'; - __resetRateLimitConfig(); - const ip = getClientIp(fakeEvent('4.4.4.4', '99.99.99.99')); - expect(ip).toBe('4.4.4.4'); - }); - - it('rate-limit bucket stays bound to peer when XFF is spoofed (trust off)', () => { - resetRateLimiter(); - // 10 attempts, all from same peer but different XFF values — they - // should all land in the same bucket and trip the limit. - for (let i = 0; i < 10; i++) { - const ip = getClientIp(fakeEvent('7.7.7.7', String(i))); - checkRateLimit(ip, 10, 60_000); - } - const ip = getClientIp(fakeEvent('7.7.7.7', '123')); - expect(checkRateLimit(ip, 10, 60_000)).toBe(false); - }); -}); diff --git a/src/lib/server/__tests__/redirects.test.ts b/src/lib/server/__tests__/redirects.test.ts deleted file mode 100644 index f0fbe3d3..00000000 --- a/src/lib/server/__tests__/redirects.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -/** - * Canonical state-machine tests for resolveRedirect. - * - * The resolver governs four onboarding entry points (/welcome, /register, - * /invite, /pending-approval) plus the general logged-in / logged-out flows. - * These tests pin the (user × status × path × settings) tuples so that - * future changes to the state machine force an explicit test update, and - * so drift between the resolver and the routes can't regress silently. - * (#32, #24) - * - * The `/setup` route was retired in #24; fresh-install admin creation now - * happens at `/welcome` too. Tests that previously pinned /setup behavior - * have been adapted to /welcome. - */ -import { describe, it, expect } from 'vitest'; -import { resolveRedirect, type RedirectUser } from '../redirects'; - -type FakeSettings = Record; - -function mkOpts(opts: { - userCount?: number; - settings?: FakeSettings; -}) { - return { - getUserCount: () => opts.userCount ?? 1, - getSetting: (key: string) => opts.settings?.[key] ?? null - }; -} - -const activeUser: RedirectUser = { - status: 'active', - forcePasswordReset: false, - welcomeCompletedAt: '2026-04-01T00:00:00Z' -}; - -const pendingUser: RedirectUser = { - status: 'pending', - forcePasswordReset: false, - welcomeCompletedAt: '2026-04-01T00:00:00Z' -}; - -const lockedUser: RedirectUser = { - status: 'active', - forcePasswordReset: true, - welcomeCompletedAt: '2026-04-01T00:00:00Z' -}; - -const freshUser: RedirectUser = { - status: 'active', - forcePasswordReset: false, - welcomeCompletedAt: null -}; - -describe('resolveRedirect — legacy URL rewrites', () => { - it('rewrites /collections → /library/catalogs (301)', () => { - const t = resolveRedirect(null, '/collections', '', mkOpts({})); - expect(t).toEqual({ location: '/library/catalogs', status: 301 }); - }); - - it('preserves sub-paths and query under /collections', () => { - const t = resolveRedirect(null, '/collections/foo', '?q=1', mkOpts({})); - expect(t).toEqual({ location: '/library/catalogs/foo?q=1', status: 301 }); - }); -}); - -describe('resolveRedirect — first-run (no users yet)', () => { - it('redirects anything-but-/welcome to /welcome', () => { - const opts = mkOpts({ userCount: 0 }); - expect(resolveRedirect(null, '/login', '', opts)).toEqual({ - location: '/welcome', - status: 303 - }); - expect(resolveRedirect(null, '/', '', opts)).toEqual({ - location: '/welcome', - status: 303 - }); - expect(resolveRedirect(null, '/register', '', opts)).toEqual({ - location: '/welcome', - status: 303 - }); - }); - - it('lets /welcome through during first-run so the admin-create form renders', () => { - const opts = mkOpts({ userCount: 0 }); - expect(resolveRedirect(null, '/welcome', '', opts)).toBeNull(); - }); - - it('first-run + /welcome sub-path passes through too', () => { - const opts = mkOpts({ userCount: 0 }); - expect(resolveRedirect(null, '/welcome/foo', '', opts)).toBeNull(); - }); - - it('first-run + user-less session on /welcome → null (admin-create form renders)', () => { - // Even with user === null (no session), userCount===0 means the /welcome - // route should render its admin-create branch. Rule 2 wins over rule 3d. - const opts = mkOpts({ userCount: 0 }); - expect(resolveRedirect(null, '/welcome', '', opts)).toBeNull(); - }); - - it('first-run does NOT redirect API paths — they bypass rule 2', () => { - // API routes are data endpoints, not browser surfaces — bouncing a - // 303 from /api/health (or any other /api/*) would crash reverse-proxy - // health checks and polling clients that don't follow redirects. - // Fixed during the jellyfin.example.local deploy smoke-test. - const opts = mkOpts({ userCount: 0 }); - expect(resolveRedirect(null, '/api/library', '', opts)).toBeNull(); - expect(resolveRedirect(null, '/api/health', '', opts)).toBeNull(); - }); -}); - -describe('resolveRedirect — /register lifecycle', () => { - it('registration disabled → /login', () => { - const t = resolveRedirect(null, '/register', '', mkOpts({ userCount: 1 })); - expect(t).toEqual({ location: '/login', status: 303 }); - }); - - it('already-logged-in → /', () => { - const opts = mkOpts({ userCount: 1, settings: { registration_enabled: 'true' } }); - const t = resolveRedirect(activeUser, '/register', '', opts); - expect(t).toEqual({ location: '/', status: 303 }); - }); - - it('anonymous + registration enabled → null', () => { - const opts = mkOpts({ userCount: 1, settings: { registration_enabled: 'true' } }); - expect(resolveRedirect(null, '/register', '', opts)).toBeNull(); - }); -}); - -describe('resolveRedirect — /invite lifecycle', () => { - it('already-logged-in → /', () => { - const t = resolveRedirect(activeUser, '/invite', '?code=abc', mkOpts({ userCount: 1 })); - expect(t).toEqual({ location: '/', status: 303 }); - }); - - it('anonymous → null (page validates the code)', () => { - expect(resolveRedirect(null, '/invite', '?code=abc', mkOpts({ userCount: 1 }))).toBeNull(); - }); -}); - -describe('resolveRedirect — /pending-approval lifecycle', () => { - it('no user → /login', () => { - const t = resolveRedirect(null, '/pending-approval', '', mkOpts({ userCount: 1 })); - expect(t).toEqual({ location: '/login', status: 303 }); - }); - - it('active user → / (they no longer belong here)', () => { - const t = resolveRedirect(activeUser, '/pending-approval', '', mkOpts({ userCount: 1 })); - expect(t).toEqual({ location: '/', status: 303 }); - }); - - it('pending user → null (let through)', () => { - const t = resolveRedirect(pendingUser, '/pending-approval', '', mkOpts({ userCount: 1 })); - expect(t).toBeNull(); - }); -}); - -describe('resolveRedirect — /welcome lifecycle (post-first-run)', () => { - it('no user (users exist) → /login', () => { - const t = resolveRedirect(null, '/welcome', '', mkOpts({ userCount: 1 })); - expect(t).toEqual({ location: '/login', status: 303 }); - }); - - it('logged-in + fresh user → null (the route renders)', () => { - const t = resolveRedirect(freshUser, '/welcome', '', mkOpts({ userCount: 1 })); - expect(t).toBeNull(); - }); - - it('logged-in + already-completed user → null (route decides via DB read + ?force=1)', () => { - // Resolver doesn't gate /welcome for already-completed users; the route's - // fresh DB read does. This test pins that contract. - const t = resolveRedirect(activeUser, '/welcome', '', mkOpts({ userCount: 1 })); - expect(t).toBeNull(); - }); -}); - -describe('resolveRedirect — logged-in lifecycle locks', () => { - it('forcePasswordReset → /reset-password (from non-API, non-lock paths)', () => { - const t = resolveRedirect(lockedUser, '/library', '', mkOpts({ userCount: 1 })); - expect(t).toEqual({ location: '/reset-password', status: 303 }); - }); - - it('pending user on a gated page → /pending-approval', () => { - const t = resolveRedirect(pendingUser, '/library', '', mkOpts({ userCount: 1 })); - expect(t).toEqual({ location: '/pending-approval', status: 303 }); - }); - - it('fresh user (no welcomeCompletedAt) → /welcome', () => { - const t = resolveRedirect(freshUser, '/library', '', mkOpts({ userCount: 1 })); - expect(t).toEqual({ location: '/welcome', status: 303 }); - }); - - it('API routes skip lock redirects (hooks gate handles them as 403s)', () => { - expect(resolveRedirect(lockedUser, '/api/library', '', mkOpts({ userCount: 1 }))).toBeNull(); - expect(resolveRedirect(pendingUser, '/api/library', '', mkOpts({ userCount: 1 }))).toBeNull(); - }); -}); - -describe('resolveRedirect — anonymous non-API → /login?next=', () => { - it('captures path + query into ?next=', () => { - const t = resolveRedirect(null, '/library', '?tab=watchlist', mkOpts({ userCount: 1 })); - expect(t?.location).toBe('/login?next=' + encodeURIComponent('/library?tab=watchlist')); - expect(t?.status).toBe(303); - }); - - it('anonymous /api/* stays null — the endpoint returns its own 401', () => { - expect(resolveRedirect(null, '/api/library', '', mkOpts({ userCount: 1 }))).toBeNull(); - }); -}); - -describe('resolveRedirect — allowlist + active user passthrough', () => { - it('/login + active user → null (resolver lets /login render; route decides)', () => { - expect(resolveRedirect(activeUser, '/login', '', mkOpts({ userCount: 1 }))).toBeNull(); - }); - - it('/reset-password + locked user → null (the user needs to reach the form)', () => { - expect( - resolveRedirect(lockedUser, '/reset-password', '', mkOpts({ userCount: 1 })) - ).toBeNull(); - }); - - it('/api/ingest/webhook stays allowlisted even for anonymous', () => { - expect( - resolveRedirect(null, '/api/ingest/webhook', '', mkOpts({ userCount: 1 })) - ).toBeNull(); - }); - - it('active user on / → null', () => { - expect(resolveRedirect(activeUser, '/', '', mkOpts({ userCount: 1 }))).toBeNull(); - }); -}); diff --git a/src/lib/server/__tests__/session-guard.test.ts b/src/lib/server/__tests__/session-guard.test.ts deleted file mode 100644 index 4832c3ea..00000000 --- a/src/lib/server/__tests__/session-guard.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { requireUser, requireActiveUser, requireAdmin, type LocalsLike } from '../session-guard'; - -function makeEvent(user: Partial> | null): LocalsLike { - return { - locals: { - user: user - ? ({ - id: user.id ?? 'u1', - username: user.username ?? 'alice', - displayName: user.displayName ?? 'Alice', - avatar: user.avatar ?? null, - isAdmin: user.isAdmin ?? false, - status: user.status ?? 'active', - forcePasswordReset: user.forcePasswordReset ?? false - } as NonNullable) - : undefined - } - }; -} - -function catchError(fn: () => unknown): { status: number; body: App.Error } { - try { - fn(); - } catch (err) { - // SvelteKit's `error()` throws a HttpError-like object with a `status`. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const e = err as any; - return { status: e.status, body: e.body }; - } - throw new Error('expected throw'); -} - -describe('session-guard', () => { - it('requireUser throws 401 with no-session reason when anon', () => { - const { status, body } = catchError(() => requireUser(makeEvent(null))); - expect(status).toBe(401); - expect(body.nexusReason).toBe('no-session'); - }); - - it('requireUser returns the user for a valid session', () => { - const u = requireUser(makeEvent({ id: 'u7' })); - expect(u.id).toBe('u7'); - }); - - it('requireActiveUser rejects pending accounts with 403', () => { - const { status, body } = catchError(() => - requireActiveUser(makeEvent({ status: 'pending' })) - ); - expect(status).toBe(403); - expect(body.nexusReason).toBe('pending-approval'); - }); - - it('requireActiveUser rejects forcePasswordReset with 403', () => { - const { status, body } = catchError(() => - requireActiveUser(makeEvent({ forcePasswordReset: true })) - ); - expect(status).toBe(403); - expect(body.nexusReason).toBe('password-reset-required'); - }); - - it('requireActiveUser accepts a normal active user', () => { - const u = requireActiveUser(makeEvent({ status: 'active', forcePasswordReset: false })); - expect(u.username).toBe('alice'); - }); - - it('requireAdmin rejects non-admin', () => { - const { status, body } = catchError(() => requireAdmin(makeEvent({ isAdmin: false }))); - expect(status).toBe(403); - expect(body.nexusReason).toBe('not-admin'); - }); - - it('requireAdmin accepts admin', () => { - const u = requireAdmin(makeEvent({ isAdmin: true })); - expect(u.isAdmin).toBe(true); - }); -}); diff --git a/src/lib/server/__tests__/speed-resolver.test.ts b/src/lib/server/__tests__/speed-resolver.test.ts deleted file mode 100644 index 09406489..00000000 --- a/src/lib/server/__tests__/speed-resolver.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { resolvePlaybackRate, type SpeedRule } from '../speed-resolver'; - -describe('resolvePlaybackRate', () => { - it('returns 1 when no rules exist', () => { - expect(resolvePlaybackRate([], 'movie')).toBe(1); - }); - - it('returns the default-rule speed when only default exists', () => { - const rules: SpeedRule[] = [{ scope: 'default', scopeValue: null, speed: 1.25 }]; - expect(resolvePlaybackRate(rules, 'movie')).toBe(1.25); - }); - - it('type rule beats default rule', () => { - const rules: SpeedRule[] = [ - { scope: 'default', scopeValue: null, speed: 1.25 }, - { scope: 'type', scopeValue: 'movie', speed: 1.0 } - ]; - expect(resolvePlaybackRate(rules, 'movie')).toBe(1.0); - // non-matching type falls back to default - expect(resolvePlaybackRate(rules, 'video')).toBe(1.25); - }); - - it('channel rule beats type rule', () => { - const rules: SpeedRule[] = [ - { scope: 'default', scopeValue: null, speed: 1.25 }, - { scope: 'type', scopeValue: 'video', speed: 1.5 }, - { scope: 'channel', scopeValue: 'ch-1', speed: 2.0 } - ]; - expect(resolvePlaybackRate(rules, 'video', 'some-video', 'ch-1')).toBe(2.0); - }); - - it('video rule beats channel rule', () => { - const rules: SpeedRule[] = [ - { scope: 'default', scopeValue: null, speed: 1.25 }, - { scope: 'channel', scopeValue: 'ch-1', speed: 2.0 }, - { scope: 'video', scopeValue: 'vid-1', speed: 1.75 } - ]; - expect(resolvePlaybackRate(rules, 'video', 'vid-1', 'ch-1')).toBe(1.75); - }); - - it('skips invalid speeds and falls through to the next rule', () => { - const rules: SpeedRule[] = [ - { scope: 'default', scopeValue: null, speed: 1.5 }, - { scope: 'type', scopeValue: 'movie', speed: 0 } // invalid - ]; - expect(resolvePlaybackRate(rules, 'movie')).toBe(1.5); - }); - - it('returns 1 when mediaType is undefined and only a type rule exists', () => { - const rules: SpeedRule[] = [{ scope: 'type', scopeValue: 'movie', speed: 2.0 }]; - expect(resolvePlaybackRate(rules, undefined)).toBe(1); - }); - - it('respects upper and lower speed bounds', () => { - const rules: SpeedRule[] = [{ scope: 'default', scopeValue: null, speed: 32 }]; - // 32 > 16 → invalid, falls back to 1 - expect(resolvePlaybackRate(rules, 'movie')).toBe(1); - }); -}); diff --git a/src/lib/server/__tests__/wrapped.test.ts b/src/lib/server/__tests__/wrapped.test.ts deleted file mode 100644 index da7df0fe..00000000 --- a/src/lib/server/__tests__/wrapped.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; -import Database from 'better-sqlite3'; - -// computeWrapped reads via getRawDb(). Stub that module before importing so -// the helper talks to an in-memory test DB. -let testDb: InstanceType; - -vi.mock('$lib/db', async () => ({ - getRawDb: () => testDb, - getDb: () => { throw new Error('not used'); } -})); - -import { computeWrapped } from '../wrapped'; - -function seed(db: InstanceType) { - db.exec(` - CREATE TABLE play_sessions ( - id TEXT PRIMARY KEY, - session_key TEXT, - user_id TEXT NOT NULL, - service_id TEXT NOT NULL, - service_type TEXT NOT NULL, - media_id TEXT NOT NULL, - media_type TEXT NOT NULL, - media_title TEXT, - media_year INTEGER, - media_genres TEXT, - parent_id TEXT, - parent_title TEXT, - started_at INTEGER NOT NULL, - ended_at INTEGER, - duration_ms INTEGER DEFAULT 0, - media_duration_ms INTEGER, - progress REAL, - completed INTEGER DEFAULT 0, - position TEXT, - position_ticks INTEGER, - device_name TEXT, - client_name TEXT, - metadata TEXT, - source TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - `); -} - -const USER = 'user-fixture'; - -function insertSession( - db: InstanceType, - id: string, - startedAt: number, - durationMs: number, - opts: Partial<{ mediaType: string; mediaId: string; title: string; genres: string[] }> = {} -) { - db.prepare( - `INSERT INTO play_sessions ( - id, user_id, service_id, service_type, media_id, media_type, - media_title, started_at, duration_ms, source, created_at, updated_at, metadata - ) VALUES (?, ?, 'svc', 'jellyfin', ?, ?, ?, ?, ?, 'test', ?, ?, ?)` - ).run( - id, - USER, - opts.mediaId ?? `media-${id}`, - opts.mediaType ?? 'movie', - opts.title ?? 'Fixture', - startedAt, - durationMs, - startedAt, - startedAt + durationMs, - opts.genres ? JSON.stringify({ genres: opts.genres }) : null - ); -} - -describe('computeWrapped', () => { - beforeAll(() => { - testDb = new Database(':memory:'); - seed(testDb); - }); - - beforeEach(() => { - testDb.exec('DELETE FROM play_sessions'); - }); - - it('returns zero hours for a year with no sessions', () => { - const result = computeWrapped(USER, 2024); - expect(result.totalHours).toBe(0); - expect(result.monthlyActivity).toEqual([]); - expect(result.streaks.longest).toBe(0); - }); - - it('sums duration across sessions within the target year', () => { - const jan = new Date(2026, 0, 15, 12, 0, 0).getTime(); - const may = new Date(2026, 4, 1, 12, 0, 0).getTime(); - insertSession(testDb, 'a', jan, 60 * 60 * 1000, { mediaType: 'movie', genres: ['drama'] }); - insertSession(testDb, 'b', may, 30 * 60 * 1000, { mediaType: 'show', genres: ['comedy'] }); - // Outside the year — must NOT count. - insertSession(testDb, 'c', new Date(2025, 11, 30).getTime(), 1000 * 60 * 60, { mediaType: 'movie' }); - - const result = computeWrapped(USER, 2026); - expect(result.totalHours).toBe(1.5); - expect(result.byType.movie.hours).toBe(1); - expect(result.byType.show.hours).toBe(0.5); - // Monthly grouping keys should be proper months like '2026-01', proof - // that the /1000 epoch conversion is landing. - expect(result.monthlyActivity.map((m) => m.month)).toEqual(['2026-01', '2026-05']); - }); - - it('computes streak across midnight correctly in user-local time', () => { - // Three consecutive local days — late-night session on day 1 should - // still count as day 1, not day 2, thanks to 'localtime'. - const day1Late = new Date(2026, 1, 10, 23, 30, 0).getTime(); - const day2 = new Date(2026, 1, 11, 10, 0, 0).getTime(); - const day3 = new Date(2026, 1, 12, 15, 0, 0).getTime(); - insertSession(testDb, 'a', day1Late, 30 * 60 * 1000); - insertSession(testDb, 'b', day2, 30 * 60 * 1000); - insertSession(testDb, 'c', day3, 30 * 60 * 1000); - - const result = computeWrapped(USER, 2026); - expect(result.streaks.longest).toBeGreaterThanOrEqual(3); - }); - - it('isolates data by user_id', () => { - insertSession(testDb, 'a', new Date(2026, 0, 5).getTime(), 60 * 60 * 1000); - testDb.prepare( - `INSERT INTO play_sessions (id, user_id, service_id, service_type, media_id, media_type, - started_at, duration_ms, source, created_at, updated_at) - VALUES ('other', 'other-user', 'svc', 'jellyfin', 'm', 'movie', ?, ?, 'test', ?, ?)` - ).run(new Date(2026, 0, 5).getTime(), 3 * 60 * 60 * 1000, 0, 0); - - const result = computeWrapped(USER, 2026); - expect(result.totalHours).toBe(1); - }); -}); diff --git a/src/lib/server/account-services.ts b/src/lib/server/account-services.ts deleted file mode 100644 index 3a57ebde..00000000 --- a/src/lib/server/account-services.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Server-side helper: build AccountServiceSummary objects from services + - * user_service_credentials + adapter capabilities. Consumed by the settings - * pages and any consumer page that renders the shared account-linking UI - * components (SignInCard, StaleCredentialBanner, AccountLinkModal). - * - * Never includes raw access tokens or stored passwords — only metadata and - * state. Safe to pass to the client. - */ - -import { and, eq } from 'drizzle-orm'; -import { getDb, schema } from '../db'; -import { registry } from '../adapters/registry'; -import { getServiceConfig, getEnabledConfigs } from './services'; -import type { AccountServiceSummary } from '../components/account-linking/types'; - -function fallbackCapabilities() { - return {} as AccountServiceSummary['capabilities']; -} - -/** - * Load a single AccountServiceSummary by service id for a specific user. - * Returns null if the service doesn't exist. - */ -export function buildAccountServiceSummary( - userId: string | null, - serviceId: string -): AccountServiceSummary | null { - const config = getServiceConfig(serviceId); - if (!config) return null; - return buildFromConfig(userId, config); -} - -/** - * Load AccountServiceSummary objects for every enabled service of a given - * adapter type. Useful for consumer pages that need to render SignInCards - * for all configured instances of one service. - */ -export function buildAccountServiceSummariesForType( - userId: string | null, - serviceType: string -): AccountServiceSummary[] { - return getEnabledConfigs() - .filter((c) => c.type === serviceType) - .map((c) => buildFromConfig(userId, c)) - .filter((s): s is AccountServiceSummary => s !== null); -} - -/** - * Load AccountServiceSummary objects for every enabled service the current - * user could link. Used by the accounts page. - */ -export function buildAllAccountServiceSummaries(userId: string): AccountServiceSummary[] { - return getEnabledConfigs() - .map((c) => buildFromConfig(userId, c)) - .filter((s): s is AccountServiceSummary => s !== null); -} - -function buildFromConfig( - userId: string | null, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - config: any -): AccountServiceSummary | null { - const adapter = registry.get(config.type); - if (!adapter) return null; - - const capabilities = adapter.capabilities ?? fallbackCapabilities(); - - // Look up the user's credential row for this service, if any. - let isLinked = false; - let staleSince: string | null = null; - let externalUsername: string | null = null; - let nexusManaged = false; - let autoLinked = false; - let hasStoredPassword = false; - let parentServiceId: string | null = null; - - if (userId) { - const db = getDb(); - const row = db - .select({ - accessToken: schema.userServiceCredentials.accessToken, - externalUsername: schema.userServiceCredentials.externalUsername, - externalUserId: schema.userServiceCredentials.externalUserId, - staleSince: schema.userServiceCredentials.staleSince, - managed: schema.userServiceCredentials.managed, - autoLinked: schema.userServiceCredentials.autoLinked, - storedPassword: schema.userServiceCredentials.storedPassword, - parentServiceId: schema.userServiceCredentials.parentServiceId - }) - .from(schema.userServiceCredentials) - .where( - and( - eq(schema.userServiceCredentials.userId, userId), - eq(schema.userServiceCredentials.serviceId, config.id) - ) - ) - .get(); - if (row) { - isLinked = !!(row.accessToken || row.externalUserId); - staleSince = row.staleSince ?? null; - externalUsername = row.externalUsername ?? null; - nexusManaged = !!row.managed; - autoLinked = !!row.autoLinked; - hasStoredPassword = !!row.storedPassword; - parentServiceId = row.parentServiceId ?? null; - } - } - - // If the credential points at a parent, resolve the parent's display name - // so the UI can show "Linked via Jellyfin" with the actual service name. - let parentServiceName: string | null = null; - if (parentServiceId) { - const parent = getServiceConfig(parentServiceId); - parentServiceName = parent?.name ?? null; - } - - return { - id: config.id, - name: config.name, - type: config.type, - url: config.url, - color: adapter.color ?? '#888', - abbreviation: adapter.abbreviation ?? config.type.slice(0, 2).toUpperCase(), - icon: adapter.icon, - capabilities, - isLinked, - staleSince, - externalUsername, - nexusManaged, - autoLinked, - hasStoredPassword, - parentServiceName - }; -} diff --git a/src/lib/server/annotations.ts b/src/lib/server/annotations.ts new file mode 100644 index 00000000..1e311e3c --- /dev/null +++ b/src/lib/server/annotations.ts @@ -0,0 +1,40 @@ +import { getRawDb } from '$lib/db'; + +// Page-anchored collaborative annotations ("stickies"), ported from the tasks +// app. Stored in the Nexus DB; collaborative (any signed-in user can add/clear). + +export type Annotation = { + id: number; + page_path: string; + anchor_selector: string; + anchor_snippet: string; + anchor_offset_x: number; + anchor_offset_y: number; + body: string; + author: string; + created_at: number; +}; + +export type NewAnnotation = Omit; + +export function listAnnotations(pagePath: string): Annotation[] { + return getRawDb() + .prepare('SELECT * FROM annotations WHERE page_path = ? ORDER BY created_at ASC') + .all(pagePath) as Annotation[]; +} + +export function addAnnotation(a: NewAnnotation): Annotation { + const db = getRawDb(); + const info = db + .prepare( + `INSERT INTO annotations + (page_path, anchor_selector, anchor_snippet, anchor_offset_x, anchor_offset_y, body, author) + VALUES (@page_path, @anchor_selector, @anchor_snippet, @anchor_offset_x, @anchor_offset_y, @body, @author)` + ) + .run(a); + return db.prepare('SELECT * FROM annotations WHERE id = ?').get(info.lastInsertRowid) as Annotation; +} + +export function deleteAnnotation(id: number): boolean { + return getRawDb().prepare('DELETE FROM annotations WHERE id = ?').run(id).changes > 0; +} diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 80bb7ab4..6a2b4882 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -273,21 +273,7 @@ export function upsertUserCredential( }) .run(); - // Fire-and-forget: auto-link derived services (e.g. Overseerr, StreamyStats) - // Uses dynamic import to avoid circular dependency with services.ts - if (!opts?.skipDerivedLink) { - import('./services.js') - .then(({ getServiceConfig }) => { - const config = getServiceConfig(serviceId); - if (!config) return; - return import('./derived-linker.js').then(({ linkDerivedServices }) => - linkDerivedServices(userId, serviceId, config.type) - ); - }) - .catch((e) => { - console.warn('[auth] Derived linker failed:', e instanceof Error ? e.message : e); - }); - } + void opts; } export function deleteUserCredential(userId: string, serviceId: string) { diff --git a/src/lib/server/auth/better-auth.ts b/src/lib/server/auth/better-auth.ts new file mode 100644 index 00000000..7fee01a7 --- /dev/null +++ b/src/lib/server/auth/better-auth.ts @@ -0,0 +1,156 @@ +// Better Auth — the Nexus identity/session/RBAC layer (Eli: don't roll your own +// auth crypto). Replaces the hand-rolled scrypt+cookie in ../auth.ts. Better Auth +// also defaults to scrypt, so this is not a crypto-philosophy change — it's +// stopping the hand-rolling and gaining RBAC, OIDC (Authentik), 2FA, and session +// revocation (which closes the gen-revocation gap from the security review). +// +// NOTE: not yet wired into hooks.server.ts. Going live needs (next increment): +// 1. `npx @better-auth/cli generate` → emit the Drizzle schema (account + +// verification tables, role/banned columns) → drizzle-kit migrate. +// 2. Backfill: one `account` row per existing user (providerId 'credential', +// password = legacy `salt:hash`) — TESTED against a DB copy first so nobody +// gets locked out. +// 3. Swap hooks to svelteKitHandler + populate locals.user/session from BA. +// The legacy-scrypt verify below means existing users keep their passwords (no +// forced reset); Better Auth rehashes to its format on next successful login. +import { betterAuth } from 'better-auth'; +import { drizzleAdapter } from 'better-auth/adapters/drizzle'; +import { username } from 'better-auth/plugins'; +import { genericOAuth } from 'better-auth/plugins'; +import { sveltekitCookies } from 'better-auth/svelte-kit'; +import { verifyPassword as baVerifyPassword } from 'better-auth/crypto'; +import { getRequestEvent } from '$app/server'; +import { building } from '$app/environment'; +import { scryptSync, timingSafeEqual } from 'node:crypto'; +import { getDb, schema } from '../../db'; + +// Fail-closed secret validation, independent of NODE_ENV (Better Auth only hard- +// fails on a missing/weak secret when isProduction, otherwise silently signs with +// a PUBLIC default — forgeable sessions). Refuse to start without a real secret. +const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET; +// Runtime-only: `vite build` imports this server module to bundle it, with no +// env present. Don't throw at build (guarded by `building`); the check still +// fail-closes at runtime, where the secret must be set. +if (!building && (!BETTER_AUTH_SECRET || BETTER_AUTH_SECRET.length < 32)) { + throw new Error( + '[auth] BETTER_AUTH_SECRET must be set to a random string of at least 32 characters.' + ); +} + +/** Verify a legacy `${salt}:${hash}` scrypt password (the pre-Better-Auth format + * from ../auth.ts — scrypt, 64-byte key). Used during migration so existing + * users authenticate without a reset; Better Auth rehashes on success. */ +export function verifyLegacyScrypt(password: string, stored: string): boolean { + if (!stored || stored.length < 10) return false; + const [salt, hash] = stored.split(':'); + if (!salt || !hash) return false; + const attempt = scryptSync(password, salt, 64); + const expected = Buffer.from(hash, 'hex'); + return attempt.length === expected.length && timingSafeEqual(attempt, expected); +} + +/** Authentik (or any) OIDC, enabled only when configured — generic-oauth plugin. */ +const oidcProviders = + process.env.NEXUS_OIDC_DISCOVERY_URL && + process.env.NEXUS_OIDC_CLIENT_ID && + process.env.NEXUS_OIDC_CLIENT_SECRET + ? [ + genericOAuth({ + config: [ + { + providerId: 'authentik', + discoveryUrl: process.env.NEXUS_OIDC_DISCOVERY_URL, + clientId: process.env.NEXUS_OIDC_CLIENT_ID, + clientSecret: process.env.NEXUS_OIDC_CLIENT_SECRET, + // OIDC requires the openid scope; email/profile populate the + // user record. Without these the authorize request sends an + // empty scope and the IdP returns no identity. + scopes: ['openid', 'email', 'profile'], + // Our users.username is NOT NULL; OIDC profiles don't carry it + // by default. Derive it from Authentik's preferred_username + // (unique), falling back to the email local-part / sub. + mapProfileToUser: (profile: Record) => { + const pu = (profile.preferred_username as string) ?? undefined; + const email = (profile.email as string) ?? undefined; + const username = pu ?? email?.split('@')[0] ?? (profile.sub as string); + const name = (profile.name as string) ?? username; + return { username, displayUsername: name, name, email }; + } + } + ] + }) + ] + : []; + +// Skip Better Auth init during `vite build` — betterAuth() opens the DB via +// getDb() and requires the secret, neither of which exist at build time. `auth` +// is only ever used inside request handlers (runtime), so a build-time stub is +// safe; the real instance is created on first server start. +export const auth = building + ? (undefined as unknown as ReturnType) + : betterAuth({ + // Map BA's models to our tables explicitly: reuse the existing `users` (keeps + // all the per-user-state FKs pointing at users.id), and use the DISTINCT + // `auth_sessions` so BA doesn't clobber the legacy `sessions` during cutover. + database: drizzleAdapter(getDb(), { + provider: 'sqlite', + schema: { + user: schema.users, + session: schema.authSessions, + account: schema.accounts, + verification: schema.verifications + } + }), + secret: BETTER_AUTH_SECRET, + // baseURL drives Better Auth's Secure-cookie derivation + CSRF origin check. + // Set it to the public https URL in prod (BETTER_AUTH_URL); over plain http + // (dev / behind a TLS-terminating proxy on http) cookies stay non-Secure. + baseURL: process.env.BETTER_AUTH_URL, + trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS?.split(',').map((s) => s.trim()), + // Link an Authentik (OIDC) login to an existing local account with the same + // verified email instead of creating a duplicate — Authentik is a trusted IdP. + account: { + accountLinking: { + enabled: true, + trustedProviders: ['authentik'] + } + }, + emailAndPassword: { + enabled: true, + // Match the existing Nexus register UI policy (6 chars) so client-side + // validation and Better Auth agree; BA defaults to 8 and would otherwise + // reject 6–7 char passwords the form accepted. + minPasswordLength: 6, + // Password hashing is left to Better Auth (its own scrypt, via + // better-auth/crypto) — we do NOT override `hash`, so new/registered users + // get BA-format hashes. Verification must accept BOTH formats: legacy rows + // migrated from the hand-rolled auth.ts use node scrypt (r=8) while BA's + // own hashes use different params (r=16); the two are the same `salt:hash` + // shape but not cross-verifiable. Try BA's native verifier first, then fall + // back to the legacy scrypt shim. Both are constant-time internally. + password: { + verify: async ({ password, hash }: { password: string; hash: string }) => { + // Reject empty/short hashes outright (callers pass `passwordHash ?? ''` + // for null-hash OIDC/service users — those must fail closed). + if (!hash || hash.length < 10) return false; + try { + if (await baVerifyPassword({ password, hash })) return true; + } catch { + // Not a BA-format hash (or malformed) — fall through to legacy. + } + return verifyLegacyScrypt(password, hash); + } + } + }, + // sveltekitCookies MUST be last — its `after` hook copies Better Auth's + // Set-Cookie onto the SvelteKit request event, so server-side auth.api calls + // (login/register form actions) set the session cookie without hand-parsing. + // NOTE: the Better Auth `admin()` plugin is intentionally NOT enabled. The app's + // RBAC is the `users.isAdmin` column (lightweight, per current design); enabling + // admin() would expose /api/auth/admin/* (set-role, ban, IMPERSONATE) keyed off a + // separate `role` column the app doesn't authorize on — a parallel privilege + // channel. Revisit when RBAC is unified under the lab IAM work. + plugins: [username(), ...oidcProviders, sveltekitCookies(getRequestEvent)] +}); + +export type Auth = typeof auth; diff --git a/src/lib/server/auto-suggest.ts b/src/lib/server/auto-suggest.ts deleted file mode 100644 index 866fa40c..00000000 --- a/src/lib/server/auto-suggest.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { getRawDb } from '$lib/db'; -import { getEnabledConfigs, resolveUserCred } from './services'; -import { registry } from '$lib/adapters/registry'; -import { withCache } from './cache'; -import type { UnifiedMedia } from '$lib/adapters/types'; - -export interface Suggestion { - item: UnifiedMedia; - reason: string; - confidence: number; -} - -/** - * Generate suggestions based on a user's recent viewing activity. - * Finds recently watched items, fetches TMDB recommendations via Seerr/Overseerr, - * and filters to content not already in the library. - */ -export async function getSuggestions(userId: string, limit = 10): Promise { - return withCache(`suggestions:${userId}`, 600_000, async () => { - const raw = getRawDb(); - const suggestions: Suggestion[] = []; - - // Get recently watched items with TMDB IDs - const recentActivity = raw.prepare(` - SELECT DISTINCT media_title, media_type, - json_extract(metadata, '$.tmdbId') as tmdb_id - FROM play_sessions - WHERE user_id = ? AND duration_ms > 300000 - ORDER BY started_at DESC - LIMIT 20 - `).all(userId) as Array<{ - media_title: string; - media_type: string; - tmdb_id: string | null; - }>; - - if (recentActivity.length === 0) return []; - - // Find adapters with getSimilar (Overseerr/Seerr) - const configs = getEnabledConfigs().filter(c => { - const adapter = registry.get(c.type); - return !!adapter?.getSimilar; - }); - - if (configs.length === 0) return []; - - const seen = new Set(); - - // For each recent item with a TMDB ID, get recommendations - for (const activity of recentActivity.slice(0, 5)) { - if (!activity.tmdb_id) continue; - - for (const config of configs) { - const adapter = registry.get(config.type); - if (!adapter?.getSimilar) continue; - const cred = resolveUserCred(config, userId); - - try { - const mediaType = activity.media_type === 'movie' ? 'movie' : 'tv'; - const similar = await adapter.getSimilar(config, `${mediaType}:${activity.tmdb_id}`, cred); - - for (const item of similar.slice(0, 3)) { - const key = `${item.sourceId}:${item.serviceId}`; - if (seen.has(key)) continue; - seen.add(key); - - // Only suggest items NOT already available - if (item.status === 'available') continue; - - suggestions.push({ - item, - reason: `Because you watched ${activity.media_title}`, - confidence: 0.7 - }); - } - } catch { continue; } - } - } - - suggestions.sort((a, b) => b.confidence - a.confidence); - return suggestions.slice(0, limit); - }); -} diff --git a/src/lib/server/boot/backfill-accounts.ts b/src/lib/server/boot/backfill-accounts.ts new file mode 100644 index 00000000..269078eb --- /dev/null +++ b/src/lib/server/boot/backfill-accounts.ts @@ -0,0 +1,64 @@ +import { randomBytes } from 'crypto'; +import { and, eq } from 'drizzle-orm'; +import { getDb, schema } from '../../db'; + +/** + * Idempotent Better Auth migration: ensure every existing credential user has a + * matching `accounts` row. + * + * The pre-BA auth (../auth.ts) stored each user's scrypt `salt:hash` in + * `users.password_hash`. Better Auth instead reads the credential password from + * the `accounts` table (providerId 'credential'), NOT from `users`. So a migrated + * database has users with a password_hash but no account row — and Better Auth's + * email/password sign-in finds no credential to verify and the user is locked out. + * + * This copies the legacy `salt:hash` verbatim into a credential `accounts` row. + * better-auth.ts's `password.verify` accepts the legacy scrypt format (it tries + * BA's own verifier first, then the legacy scrypt shim), so no password reset is + * needed — users keep their existing passwords. + * + * Runs on every boot (after the users-table rebuild in initDb). Idempotent: + * skips users who already have a credential account, and users with no usable + * password_hash (OIDC-only / service users — their password lives elsewhere or + * nowhere). Safe to run repeatedly and on fresh installs (no-op when there are + * no legacy-password users). + */ +export function backfillCredentialAccounts(): void { + const db = getDb(); + const users = db + .select({ id: schema.users.id, hash: schema.users.passwordHash }) + .from(schema.users) + .all(); + + let created = 0; + const now = new Date(); + for (const u of users) { + // Mirror better-auth.ts's verify guard: a hash under 10 chars can't be a + // real salt:hash, so it would never verify — don't manufacture a dead row. + if (!u.hash || u.hash.length < 10) continue; + + const existing = db + .select({ id: schema.accounts.id }) + .from(schema.accounts) + .where(and(eq(schema.accounts.userId, u.id), eq(schema.accounts.providerId, 'credential'))) + .get(); + if (existing) continue; + + db.insert(schema.accounts) + .values({ + id: randomBytes(16).toString('hex'), + accountId: u.id, // Better Auth keys credential accounts by the user id + providerId: 'credential', + userId: u.id, + password: u.hash, // legacy salt:hash; verify() accepts it + createdAt: now, + updatedAt: now + }) + .run(); + created++; + } + + if (created > 0) { + console.log(`[backfill] Created ${created} credential account row(s) for existing users (Better Auth migration)`); + } +} diff --git a/src/lib/server/boot/index.ts b/src/lib/server/boot/index.ts index bf0c75b2..f561152d 100644 --- a/src/lib/server/boot/index.ts +++ b/src/lib/server/boot/index.ts @@ -18,6 +18,8 @@ import { startScheduler } from './scheduler'; import { startStreamProxy } from './proxy'; import { startWatchdog } from './watchdog'; import { startLifecycle } from './lifecycle'; +import { seedServicesFromEnv } from './seed-services'; +import { backfillCredentialAccounts } from './backfill-accounts'; let booted = false; @@ -25,6 +27,10 @@ let booted = false; export function boot(): void { if (booted) return; initCrypto(); + seedServicesFromEnv(); + // Migrate legacy password users into Better Auth `accounts` rows (idempotent). + // Must run before the app serves any auth request, or migrated users can't log in. + backfillCredentialAccounts(); startPoller(); startScheduler(); startWatchdog(); diff --git a/src/lib/server/boot/poller.ts b/src/lib/server/boot/poller.ts index af7fa469..a2b1fbf8 100644 --- a/src/lib/server/boot/poller.ts +++ b/src/lib/server/boot/poller.ts @@ -1,19 +1,6 @@ -// CANONICAL: single source for session/polling background loops. -// -// Polling concerns: session validity sweeps and video-notification fanout. -// Both start interval timers; if teardown becomes needed, add stop* exports -// that mirror start*. Today the app runs until the process exits, so the -// pollers deliberately have no public stop hook. - -import { startSessionPoller } from '$lib/server/session-poller'; -import { startVideoNotificationPoller } from '$lib/server/video-notifications'; - let started = false; -/** Starts all module-owned polling loops. Idempotent. */ export function startPoller(): void { if (started) return; - startSessionPoller(); - startVideoNotificationPoller(); started = true; } diff --git a/src/lib/server/boot/proxy.ts b/src/lib/server/boot/proxy.ts index 1c3626f4..e0cbc35e 100644 --- a/src/lib/server/boot/proxy.ts +++ b/src/lib/server/boot/proxy.ts @@ -6,8 +6,8 @@ // is passed in when available so Invidious-bound streams can be served if a // service is configured later. -import { startStreamProxy as startStreamProxyImpl } from '$lib/server/stream-proxy'; -import { getEnabledConfigs } from '$lib/server/services'; +import { startStreamProxy as startStreamProxyImpl, type HeldCredTable } from '$lib/server/stream-proxy'; +import { resolveServiceConfig } from '$lib/server/v2-services'; let started = false; @@ -18,7 +18,27 @@ let started = false; */ export function startStreamProxy(): void { if (started) return; - const invConfig = getEnabledConfigs().find((c) => c.type === 'invidious'); - startStreamProxyImpl(invConfig?.url ?? 'http://localhost:3000'); + const invConfig = resolveServiceConfig('invidious'); + // Phase-0 env shim (mirrors resolveServiceConfig): the Invidious instance URL + // comes from NEXUS_INVIDIOUS_URL. Used both as the legacy /v fallback base AND + // as a held cred for the `invidious` backend so the v2 DASH/seg grant routes + // (`/v/{id}/dash`, `/v/{id}/seg/...`) resolve the real instance. Invidious is + // public, so no auth header is injected (empty name/value). + const invidiousUrl = + process.env.NEXUS_INVIDIOUS_URL ?? invConfig?.url ?? 'http://localhost:3000'; + const heldCreds: HeldCredTable = invConfig?.url + ? { + invidious: { + base_url: invConfig.url.replace(/\/+$/, ''), + auth_header_name: '', + auth_header_value: '' + } + } + : {}; + startStreamProxyImpl({ + invidiousUrl, + heldCreds, + streamSecret: process.env.NEXUS_STREAM_SECRET, + }); started = true; } diff --git a/src/lib/server/boot/scheduler.ts b/src/lib/server/boot/scheduler.ts index b78b4fd4..92fdef28 100644 --- a/src/lib/server/boot/scheduler.ts +++ b/src/lib/server/boot/scheduler.ts @@ -1,19 +1,6 @@ -// CANONICAL: single source for background job scheduling. -// -// Scheduling concerns: daily/periodic analytic aggregations (stats-scheduler) -// and recommendation-engine refresh (rec-scheduler). Both run on fixed cadence -// and depend on crypto-decrypted credentials being available — so they boot -// strictly after initCrypto(). - -import { startStatsScheduler } from '$lib/server/stats-scheduler'; -import { startRecScheduler } from '$lib/server/rec-scheduler'; - let started = false; -/** Starts all module-owned background schedulers. Idempotent. */ export function startScheduler(): void { if (started) return; - startStatsScheduler(); - startRecScheduler(); started = true; } diff --git a/src/lib/server/boot/seed-services.ts b/src/lib/server/boot/seed-services.ts new file mode 100644 index 00000000..d466dbb1 --- /dev/null +++ b/src/lib/server/boot/seed-services.ts @@ -0,0 +1,48 @@ +import { randomBytes } from 'crypto'; +import { eq } from 'drizzle-orm'; +import { getDb, schema } from '../../db'; + +/** + * One-time bootstrap seed: if Jellyfin/Invidious are configured via the Phase-0 + * env shim but have no row in the `services` table yet, create one so the DB is + * the single source of truth (and the service is editable in the admin UI). + * + * Idempotent — only inserts when no service of that type exists, so admin edits + * (or a later delete) are not clobbered on the next boot. The env vars remain a + * fallback in resolveServiceConfig for installs that haven't seeded yet. + */ +export function seedServicesFromEnv(): void { + const db = getDb(); + const hasType = (type: string): boolean => + !!db.select().from(schema.services).where(eq(schema.services.type, type)).get(); + + const jfUrl = process.env.NEXUS_JELLYFIN_URL; + const jfKey = process.env.NEXUS_JELLYFIN_APIKEY; + if (jfUrl && jfKey && !hasType('jellyfin')) { + db.insert(schema.services) + .values({ + id: `jellyfin-${randomBytes(3).toString('hex')}`, + name: 'Jellyfin', + type: 'jellyfin', + url: jfUrl.replace(/\/+$/, ''), + apiKey: jfKey, + enabled: true + }) + .run(); + console.log('[seed] Created jellyfin service row from env shim'); + } + + const invUrl = process.env.NEXUS_INVIDIOUS_URL; + if (invUrl && !hasType('invidious')) { + db.insert(schema.services) + .values({ + id: `invidious-${randomBytes(3).toString('hex')}`, + name: 'Invidious', + type: 'invidious', + url: invUrl.replace(/\/+$/, ''), + enabled: true + }) + .run(); + console.log('[seed] Created invidious service row from env shim'); + } +} diff --git a/src/lib/server/boot/watchdog.ts b/src/lib/server/boot/watchdog.ts index 5e5011fe..19daeb5b 100644 --- a/src/lib/server/boot/watchdog.ts +++ b/src/lib/server/boot/watchdog.ts @@ -1,23 +1,6 @@ -// CANONICAL: single source for health-watchdog + service-recovery fanout. -// -// The watchdog detects when a previously-failing service comes back online -// and fires a recovery event. We subscribe here to broadcast the recovery to -// every connected WS client so their caches get invalidated. - -import { startHealthWatchdog, onServiceRecovery } from '$lib/server/health-watchdog'; -import { broadcastToAll } from '$lib/server/ws'; - let started = false; -/** Starts the health watchdog and wires service-recovery broadcasts. Idempotent. */ export function startWatchdog(): void { if (started) return; - startHealthWatchdog(); - onServiceRecovery((recoveredIds) => { - broadcastToAll({ - type: 'services:recovered', - data: { serviceIds: recoveredIds } - }); - }); started = true; } diff --git a/src/lib/server/collection-activity.ts b/src/lib/server/collection-activity.ts index aca1ccce..794987dc 100644 --- a/src/lib/server/collection-activity.ts +++ b/src/lib/server/collection-activity.ts @@ -81,7 +81,7 @@ export function getCollectionActivity( return { ...e, username: user?.username, - displayName: user?.displayName, + displayName: user?.displayName ?? user?.username, avatar: user?.avatar ?? null }; }); @@ -195,7 +195,7 @@ export function getRecentCollectionUpdates( latestActivity: { ...activity, username: user?.username, - displayName: user?.displayName, + displayName: user?.displayName ?? user?.username, avatar: user?.avatar ?? null }, posters: posterMap.get(colId) ?? [] diff --git a/src/lib/server/continue-watching.ts b/src/lib/server/continue-watching.ts deleted file mode 100644 index 6eca3995..00000000 --- a/src/lib/server/continue-watching.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Canonical Continue Watching helper. - * - * Reads from `play_sessions` (the unified data model) ordered by - * `updated_at DESC` — that is, by recency, not by fake progress ratios. - * This is the single source of truth for the Continue Watching row on - * the homepage (and any future dedicated surface). - * - * Progress filters: - * - `completed = 0` — never resurface finished items - * - `progress > PROGRESS_MIN` (0.02) — filter "barely started" noise - * - `progress < PROGRESS_MAX` (0.9) — matches the session-poller - * completion threshold - * - * See docs/superpowers/specs/2026-04-17-player-alignment-plan.md §2. - */ - -import { and, desc, eq, gt, isNotNull, lt } from 'drizzle-orm'; -import { registry } from '../adapters/registry'; -import type { ServiceConfig, UnifiedMedia } from '../adapters/types'; -import { getDb, schema } from '../db'; -import { getUserCredentialForService } from './auth'; - -export const CW_PROGRESS_MIN = 0.02; -export const CW_PROGRESS_MAX = 0.9; -export const CW_DEFAULT_LIMIT = 25; - -function resolveUserCred(config: ServiceConfig, userId: string, configs: ServiceConfig[]) { - const adapter = registry.get(config.type); - if (adapter?.authVia) { - const authConfig = configs.find((c) => c.type === adapter.authVia); - if (!authConfig) return undefined; - return getUserCredentialForService(userId, authConfig.id) ?? undefined; - } - if (!adapter?.userLinkable) return undefined; - return getUserCredentialForService(userId, config.id) ?? undefined; -} - -/** - * Canonical Continue Watching source. - * - * Returns items in `updated_at DESC` order, filtered to in-progress rows, - * with adapter-resolved metadata. Order from the DB is preserved — - * callers MUST NOT re-sort by progress. - */ -export async function getContinueWatching( - userId: string, - opts: { limit?: number; configs?: ServiceConfig[] } = {} -): Promise { - const limit = opts.limit ?? CW_DEFAULT_LIMIT; - const db = getDb(); - - const rows = db - .select({ - mediaId: schema.playSessions.mediaId, - serviceId: schema.playSessions.serviceId, - serviceType: schema.playSessions.serviceType, - progress: schema.playSessions.progress, - updatedAt: schema.playSessions.updatedAt, - }) - .from(schema.playSessions) - .where( - and( - eq(schema.playSessions.userId, userId), - eq(schema.playSessions.completed, 0), - isNotNull(schema.playSessions.progress), - gt(schema.playSessions.progress, CW_PROGRESS_MIN), - lt(schema.playSessions.progress, CW_PROGRESS_MAX) - ) - ) - .orderBy(desc(schema.playSessions.updatedAt)) - // Oversample so deduping by (serviceId, mediaId) below still yields - // `limit` unique rows even when a user has several in-progress sessions - // for the same title (e.g. binging a series). Codex-audit round 2 P2. - .limit(limit * 4) - .all(); - - if (rows.length === 0) return []; - - // Resolve each row to UnifiedMedia. We need configs for cred resolution; - // accept them via opts to avoid circular imports with services.ts. - const configs = opts.configs ?? []; - const seen = new Set(); - - // Dedupe by mediaId+serviceId BEFORE resolving (cheap), preserving order, - // then cap to the requested limit. Oversample above guarantees we reach - // `limit` unique items when available. - const uniqueRows = rows.filter((row) => { - const key = `${row.serviceId}:${row.mediaId}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }).slice(0, limit); - - // Resolve items in parallel, but preserve DB order by index. - const resolved = await Promise.allSettled( - uniqueRows.map(async (row) => { - const config = configs.find((c) => c.id === row.serviceId); - if (!config) return null; - const adapter = registry.get(config.type); - if (!adapter?.getItem) return null; - const cred = resolveUserCred(config, userId, configs); - try { - const item = await adapter.getItem(config, row.mediaId, cred); - if (!item) return null; - // Stamp progress from the play_sessions row — this is the - // canonical number, not whatever the adapter's getItem call - // returned (which might be stale or missing). - item.progress = row.progress ?? item.progress; - return item; - } catch { - return null; - } - }) - ); - - // Preserve DB order (recency). - return resolved - .map((r) => (r.status === 'fulfilled' ? r.value : null)) - .filter((x): x is UnifiedMedia => x !== null); -} diff --git a/src/lib/server/derived-linker.ts b/src/lib/server/derived-linker.ts deleted file mode 100644 index 0d7370eb..00000000 --- a/src/lib/server/derived-linker.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Derived Service Auto-Linker - * - * When a parent credential (e.g. Jellyfin) is stored, this module automatically - * links derived services (e.g. Overseerr, StreamyStats) that depend on the parent. - * - * Two linking strategies: - * A) authVia delegation — copy parent token (StreamyStats pattern) - * B) User matching — find user by jellyfinUserId/plexUserId (Overseerr pattern) - * - * All linking is best-effort: errors are caught and logged but never thrown. - */ - -import { registry } from '../adapters/registry'; -import { importJellyfinUser } from '../adapters/overseerr'; -import type { ServiceConfig, UserCredential } from '../adapters/types'; -import { getEnabledConfigs, getServiceConfig } from './services'; -import { getUserCredentialForService, upsertUserCredential } from './auth'; - -/** - * Attempt to auto-link all derived services after a parent credential is stored. - * - * @param userId - Nexus user ID - * @param parentServiceId - The service whose credential was just stored - * @param parentServiceType - The adapter type of the parent service (e.g. 'jellyfin') - */ -export async function linkDerivedServices( - userId: string, - parentServiceId: string, - parentServiceType: string -): Promise { - const configs = getEnabledConfigs(); - const parentCred = getUserCredentialForService(userId, parentServiceId); - if (!parentCred) return; - - for (const config of configs) { - // Skip the parent service itself - if (config.id === parentServiceId) continue; - - const adapter = registry.get(config.type); - if (!adapter) continue; - - // Only process adapters that declare this parent as a derivation source - if (!adapter.derivedFrom?.includes(parentServiceType)) continue; - - // Skip if user already has credentials for this service - const existing = getUserCredentialForService(userId, config.id); - if (existing?.externalUserId) continue; - - try { - if (adapter.authVia === parentServiceType && parentCred.accessToken) { - // Strategy A: authVia delegation (e.g. StreamyStats uses Jellyfin token) - await linkViaAuthDelegation(userId, config, parentCred); - } else if (adapter.getUsers) { - // Strategy B: User matching (e.g. Overseerr matches by jellyfinUserId) - await linkViaUserMatching(userId, config, adapter, parentCred); - } - } catch (e) { - // Best-effort — swallow all errors - console.warn( - `[derived-linker] Failed to auto-link ${config.type} (${config.id}) for user ${userId}:`, - e instanceof Error ? e.message : e - ); - } - } -} - -/** - * Strategy A: Validate the parent token against the derived service, then store it. - * Mirrors the StreamyStats auto-link pattern from the credential API. - */ -async function linkViaAuthDelegation( - userId: string, - config: ServiceConfig, - parentCred: UserCredential -): Promise { - if (config.type === 'streamystats') { - // Resolve the Streamystats-side serverUrl by matching Jellyfin server GUID. - const { getServiceConfigs, resolveStreamystatsServerUrl } = await import('./services'); - const jfSvc = getServiceConfigs().find((s) => s.type === 'jellyfin' && s.enabled); - if (!jfSvc) return; - const jfUrl = await resolveStreamystatsServerUrl(config, jfSvc); - if (!jfUrl) return; - const testUrl = new URL(`${config.url.replace(/\/+$/, '')}/api/recommendations`); - testUrl.searchParams.set('serverUrl', jfUrl); - testUrl.searchParams.set('limit', '1'); - - const res = await fetch(testUrl.toString(), { - headers: { Authorization: `MediaBrowser Token="${parentCred.accessToken}"` }, - signal: AbortSignal.timeout(8000) - }); - - if (!res.ok) { - console.warn( - `[derived-linker] StreamyStats rejected parent token (${res.status}) for service ${config.id}` - ); - return; - } - } - - // Token validated (or no specific validation needed) — store the credential - upsertUserCredential( - userId, - config.id, - { - accessToken: parentCred.accessToken, - externalUserId: parentCred.externalUserId, - externalUsername: parentCred.externalUsername - }, - { managed: true, linkedVia: config.type, skipDerivedLink: true } - ); -} - -/** - * Strategy B: Look up users on the derived service and match by parent external ID. - * Mirrors the Overseerr auto-link pattern from the credential API. - */ -async function linkViaUserMatching( - userId: string, - config: ServiceConfig, - adapter: { getUsers?: (config: ServiceConfig) => Promise> }, - parentCred: UserCredential -): Promise { - if (!adapter.getUsers || !parentCred.externalUserId) return; - - let users = await adapter.getUsers(config); - let match = users.find((u) => u.jellyfinUserId === parentCred.externalUserId); - - // If no match and this is Overseerr, try importing the Jellyfin user first - if (!match && config.type === 'overseerr') { - const imported = await importJellyfinUser(config, parentCred.externalUserId!); - if (imported) { - users = await adapter.getUsers(config); - match = users.find((u) => u.jellyfinUserId === parentCred.externalUserId); - } - } - - if (!match) return; - - upsertUserCredential( - userId, - config.id, - { - accessToken: '', - externalUserId: match.externalId, - externalUsername: match.username - }, - { managed: true, linkedVia: config.type, skipDerivedLink: true } - ); -} diff --git a/src/lib/server/franchise.ts b/src/lib/server/franchise.ts deleted file mode 100644 index 59ff3a07..00000000 --- a/src/lib/server/franchise.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { registry } from '../adapters/registry'; -import type { UnifiedMedia } from '../adapters/types'; -import { getEnabledConfigs, resolveUserCred } from './services'; -import { withCache } from './cache'; - -export interface FranchiseData { - name: string; - movies: UnifiedMedia[]; - shows: UnifiedMedia[]; - books: UnifiedMedia[]; - games: UnifiedMedia[]; - music: UnifiedMedia[]; - videos: UnifiedMedia[]; -} - -const MEDIA_TYPE_BUCKETS: Record> = { - movie: 'movies', - show: 'shows', - episode: 'shows', - book: 'books', - game: 'games', - music: 'music', - album: 'music', - video: 'videos' -}; - -/** - * Build a franchise page by searching across all services for related content. - * Uses a franchise name (e.g. "Star Wars", "Dune", "Batman") to find matches. - */ -export async function getFranchiseData(name: string, userId: string): Promise { - return withCache(`franchise:${name.toLowerCase()}:${userId}`, 1_800_000, async () => { - const results: FranchiseData = { - name, - movies: [], - shows: [], - books: [], - games: [], - music: [], - videos: [] - }; - - const configs = getEnabledConfigs(); - - await Promise.allSettled( - configs.map(async (config) => { - const adapter = registry.get(config.type); - if (!adapter?.search) return; - - const cred = resolveUserCred(config, userId); - try { - const searchResult = await adapter.search(config, name, cred); - for (const item of searchResult.items) { - const bucket = MEDIA_TYPE_BUCKETS[item.type]; - if (bucket) results[bucket].push(item); - } - } catch { - /* silent — best-effort across all services */ - } - }) - ); - - // Deduplicate within each category - results.movies = dedup(results.movies); - results.shows = dedup(results.shows); - results.books = dedup(results.books); - results.games = dedup(results.games); - results.music = dedup(results.music); - results.videos = dedup(results.videos); - - return results; - }); -} - -function dedup(items: UnifiedMedia[]): UnifiedMedia[] { - const seen = new Map(); - for (const item of items) { - const key = item.sourceId + ':' + item.serviceId; - if (!seen.has(key)) seen.set(key, item); - } - return Array.from(seen.values()); -} diff --git a/src/lib/server/health-watchdog.ts b/src/lib/server/health-watchdog.ts deleted file mode 100644 index 61167b87..00000000 --- a/src/lib/server/health-watchdog.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * Health Watchdog — background monitor that detects when backend services - * recover from outages and instantly invalidates stale caches. - * - * Without this, a downed backend produces empty/partial data that gets cached - * for the full TTL (30s–300s). The watchdog detects offline→online transitions - * and flushes caches so the very next request picks up the recovered service. - */ - -import { registry } from '../adapters/registry'; -import type { ServiceConfig, ServiceHealth } from '../adapters/types'; -import { getServiceConfigs } from './services'; -import { invalidateAll, invalidatePrefix } from './cache'; - -// ── State ──────────────────────────────────────────────────────────────── - -/** Last known health per service ID: true = online, false = offline */ -const lastKnown = new Map(); - -let watchdogInterval: ReturnType | null = null; - -const WATCHDOG_INTERVAL_MS = 15_000; // Check every 15s -const PING_TIMEOUT_MS = 5_000; - -/** Callback invoked when one or more services transition from offline → online */ -let onRecovery: ((recovered: string[]) => void) | null = null; - -// ── Public API ─────────────────────────────────────────────────────────── - -/** - * Register a callback that fires whenever services recover. - * The callback receives an array of recovered service IDs. - */ -export function onServiceRecovery(cb: (recovered: string[]) => void): void { - onRecovery = cb; -} - -export function startHealthWatchdog(): void { - if (watchdogInterval) return; - console.log('[watchdog] Starting health watchdog (every 15s)'); - - // Seed initial state (all unknown → first check populates it) - runHealthCheck().catch(() => {}); - - watchdogInterval = setInterval(() => { - runHealthCheck().catch((e) => - console.error('[watchdog] Health check cycle error:', e) - ); - }, WATCHDOG_INTERVAL_MS); -} - -export function stopHealthWatchdog(): void { - if (watchdogInterval) { - clearInterval(watchdogInterval); - watchdogInterval = null; - console.log('[watchdog] Health watchdog stopped'); - } -} - -/** Get current known health state for all tracked services */ -export function getKnownHealth(): Map { - return new Map(lastKnown); -} - -// ── Core ───────────────────────────────────────────────────────────────── - -async function runHealthCheck(): Promise { - const configs = getServiceConfigs(); - if (configs.length === 0) return; - - const results = await Promise.allSettled( - configs.map((config) => pingService(config)) - ); - - const recovered: string[] = []; - - for (let i = 0; i < configs.length; i++) { - const config = configs[i]; - const result = results[i]; - const isOnline = result.status === 'fulfilled' && result.value; - - const wasOnline = lastKnown.get(config.id); - - // Detect offline → online transition - if (isOnline && wasOnline === false) { - console.log(`[watchdog] Service recovered: ${config.name} (${config.type})`); - recovered.push(config.id); - } - - // Detect online → offline transition (log only) - if (!isOnline && wasOnline === true) { - console.warn(`[watchdog] Service went offline: ${config.name} (${config.type})`); - } - - lastKnown.set(config.id, isOnline); - } - - // Clean up entries for services that no longer exist - for (const id of lastKnown.keys()) { - if (!configs.some((c) => c.id === id)) { - lastKnown.delete(id); - } - } - - if (recovered.length > 0) { - handleRecovery(recovered, configs); - } -} - -async function pingService(config: ServiceConfig): Promise { - const adapter = registry.get(config.type); - if (!adapter) return false; - - try { - const ping = adapter.ping(config); - const timeout = new Promise((_, reject) => - setTimeout(() => reject(new Error('timeout')), PING_TIMEOUT_MS) - ); - const result = await Promise.race([ping, timeout]); - return result.online; - } catch { - return false; - } -} - -/** - * When services recover, invalidate all caches that may contain stale - * empty/partial data from when the service was down. - */ -function handleRecovery(recoveredIds: string[], configs: ServiceConfig[]): void { - // Invalidate health cache so next health check returns fresh data - invalidatePrefix('health'); - - // Invalidate all content caches that aggregate across services - invalidatePrefix('cw:'); // continue watching - invalidatePrefix('new-in-library'); - invalidatePrefix('library:'); - invalidatePrefix('live-channels:'); - invalidatePrefix('queue'); - invalidatePrefix('admin-'); - invalidatePrefix('ss-recs-'); - invalidatePrefix('discover:'); - invalidatePrefix('trending:'); - invalidatePrefix('recently-added'); - - // Invalidate search caches (if any) - invalidatePrefix('search:'); - - console.log(`[watchdog] Invalidated caches for ${recoveredIds.length} recovered service(s)`); - - // Notify via callback (used by WS to push to clients) - if (onRecovery) { - try { - const names = recoveredIds.map((id) => { - const cfg = configs.find((c) => c.id === id); - return cfg?.name ?? id; - }); - onRecovery(recoveredIds); - } catch (e) { - console.error('[watchdog] Recovery callback error:', e); - } - } -} diff --git a/src/lib/server/homepage-cache.ts b/src/lib/server/homepage-cache.ts deleted file mode 100644 index 58957ea8..00000000 --- a/src/lib/server/homepage-cache.ts +++ /dev/null @@ -1,456 +0,0 @@ -import { getRawDb } from '$lib/db'; -import { withCache, withStaleCache, invalidate } from './cache'; -import type { ScoredRecommendation, ReasonType } from './recommendations/types'; -import type { UnifiedMedia } from '$lib/adapters/types'; - -// Re-export shared types so existing server imports still work -export type { HeroItem, HomepageItem, HomepageRow, HomepageCache } from '$lib/types/homepage'; -import type { HeroItem, HomepageItem, HomepageRow, HomepageCache } from '$lib/types/homepage'; - -// --------------------------------------------------------------------------- -// CANONICAL: Homepage row assembly + ordering. -// -// This module is the single source of truth for: -// - what rows the homepage can show (assembly in buildHomepageCache) -// - the default order those rows appear in (DEFAULT_ROW_ORDER) -// - how user-specified rowOrder is applied (applyRowOrder) -// -// The `+page.server.ts` loader MUST pass every homepage row — including -// live-only rows (continue, calendar, upcoming-*, suggestions, new) — through -// applyRowOrder so user ordering reaches them. Do NOT positionally hardcode -// rows in `+page.svelte`. If a new homepage row type ships, add it to -// DEFAULT_ROW_ORDER here so it has a deterministic default slot. -// -// Continue Watching is the one exception: it is pinned to position 0 at serve -// time (see applyRowOrder's caller) because it changes on every play/pause. -// -// Contract reference: docs/superpowers/specs/2026-03-11-personalized-homepage-design.md -// --------------------------------------------------------------------------- - -const MIN_ROW_ITEMS = 3; - -function optimizeHomepageImage( - url: string | undefined, - kind: 'poster' | 'hero-backdrop' | 'thumb-backdrop' -): string | undefined { - if (!url) return undefined; - - try { - const parsed = new URL(url); - const isJellyfinImage = parsed.pathname.includes('/Items/') && parsed.pathname.includes('/Images/'); - if (!isJellyfinImage) return url; - - if (kind === 'poster') { - parsed.searchParams.set('quality', '78'); - parsed.searchParams.set('maxWidth', '360'); - } else if (kind === 'hero-backdrop') { - parsed.searchParams.set('quality', '72'); - parsed.searchParams.set('maxWidth', '960'); - } else { - parsed.searchParams.set('quality', '74'); - parsed.searchParams.set('maxWidth', '640'); - } - - return parsed.toString(); - } catch { - return url; - } -} - -function proxyHomepageImage(url: string | undefined, serviceId: string): string | undefined { - if (!url) return undefined; - - try { - const parsed = new URL(url); - const path = `${parsed.pathname}${parsed.search}`; - return `/api/media/image?service=${encodeURIComponent(serviceId)}&path=${encodeURIComponent(path)}`; - } catch { - return url; - } -} - -function homepageImage(url: string | undefined, serviceId: string, kind: 'poster' | 'hero-backdrop' | 'thumb-backdrop'): string | undefined { - return proxyHomepageImage(optimizeHomepageImage(url, kind), serviceId); -} - -/** Format seconds into "Xh Ym" */ -function formatDuration(secs?: number): string | undefined { - if (!secs) return undefined; - const h = Math.floor(secs / 3600); - const m = Math.floor((secs % 3600) / 60); - return h > 0 ? `${h}h ${m}m` : `${m}m`; -} - -/** Convert a ScoredRecommendation to a HomepageItem */ -function recToItem(rec: ScoredRecommendation, context?: string): HomepageItem { - const item = rec.item; - return { - id: item.id, - sourceId: item.sourceId, - serviceId: item.serviceId, - serviceType: item.serviceType, - title: item.title, - poster: homepageImage(item.poster, item.serviceId, 'poster'), - backdrop: homepageImage(item.backdrop, item.serviceId, 'thumb-backdrop'), - year: item.year, - mediaType: item.type, - genres: item.genres, - rating: item.rating, - context, - streamUrl: item.streamUrl, - description: item.description - }; -} - -/** Convert a ScoredRecommendation to a HeroItem */ -function recToHero(rec: ScoredRecommendation): HeroItem { - const item = rec.item; - return { - id: item.id, - sourceId: item.sourceId, - serviceId: item.serviceId, - serviceType: item.serviceType, - title: item.title, - year: item.year, - runtime: formatDuration(item.duration), - rating: item.rating, - overview: item.description, - backdrop: homepageImage(item.backdrop, item.serviceId, 'hero-backdrop'), - poster: homepageImage(item.poster, item.serviceId, 'poster'), - mediaType: item.type, - genres: item.genres, - reason: rec.reason, - provider: rec.provider, - streamUrl: item.streamUrl, - // trailerVideo/trailerAudio resolved lazily by HeroCarousel via /api/media/[id]/trailer - }; -} - -/** Convert a UnifiedMedia (continue watching) to a HomepageItem */ -export function cwToItem(item: UnifiedMedia): HomepageItem { - const progress = item.progress ?? 0; - const remaining = item.duration ? item.duration * (1 - progress) : 0; - const h = Math.floor(remaining / 3600); - const m = Math.floor((remaining % 3600) / 60); - const timeRemaining = remaining > 0 - ? (h > 0 ? `${h}h ${m}m left` : `${m}m left`) - : undefined; - - // Extract episode info from metadata or title pattern - const season = item.metadata?.parentIndexNumber ?? item.metadata?.season; - const episode = item.metadata?.indexNumber ?? item.metadata?.episode; - const episodeInfo = season != null && episode != null - ? `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}` - : undefined; - - return { - id: item.id, - sourceId: item.sourceId, - serviceId: item.serviceId, - serviceType: item.serviceType, - title: item.title, - poster: homepageImage(item.poster, item.serviceId, 'poster'), - backdrop: homepageImage(item.thumb ?? item.backdrop, item.serviceId, 'thumb-backdrop'), - year: item.year, - mediaType: item.type, - genres: item.genres, - rating: item.rating, - progress, - timeRemaining, - episodeInfo, - streamUrl: item.streamUrl, - description: item.description - }; -} - -export { optimizeHomepageImage }; -export { homepageImage }; - -// Reason types that map to specific named rows -const TRENDING_REASONS: ReasonType[] = ['trending']; -const FRIEND_REASONS: ReasonType[] = ['friend_shared', 'friend_watched']; -const TIME_REASONS: ReasonType[] = ['time_pattern']; -const GENRE_REASONS: ReasonType[] = ['genre_match']; -// Everything else goes to "Recommended for You" -const CATCH_ALL_REASONS: ReasonType[] = [ - 'similar_users', 'similar_item', 'studio_match', - 'era_match', 'completion_pattern', 'external' -]; - -/** - * Build homepage cache from pre-computed recommendations. - * Reads from recommendation_cache table (already populated by rec scheduler). - */ -export function buildHomepageCache(userId: string): HomepageCache | null { - const raw = getRawDb(); - - // Read all cached recommendations for this user - const rows = raw.prepare( - `SELECT results FROM recommendation_cache WHERE user_id = ?` - ).all(userId) as Array<{ results: string }>; - - if (rows.length === 0) return null; - - const allRecs: ScoredRecommendation[] = []; - for (const row of rows) { - try { - const parsed = JSON.parse(row.results) as ScoredRecommendation[]; - allRecs.push(...parsed); - } catch { /* skip malformed */ } - } - - if (allRecs.length === 0) return null; - - // Deduplicate by sourceId (keep highest score) - const seen = new Map(); - for (const rec of allRecs.sort((a, b) => b.score - a.score)) { - const key = rec.item.sourceId; - if (!seen.has(key)) seen.set(key, rec); - } - const deduped = Array.from(seen.values()); - - // Hero: top 8 with backdrops - const heroRecs = deduped - .filter((r) => r.item.backdrop) - .slice(0, 8); - const hero = heroRecs.map(recToHero); - - // Exclude hero items from rows - const heroIds = new Set(heroRecs.map((r) => r.item.sourceId)); - const remaining = deduped.filter((r) => !heroIds.has(r.item.sourceId)); - - // Group by reason type - const trending = remaining.filter((r) => TRENDING_REASONS.includes(r.reasonType)); - const friends = remaining.filter((r) => FRIEND_REASONS.includes(r.reasonType)); - const timeAware = remaining.filter((r) => TIME_REASONS.includes(r.reasonType)); - const genreMatch = remaining.filter((r) => GENRE_REASONS.includes(r.reasonType)); - const catchAll = remaining.filter((r) => CATCH_ALL_REASONS.includes(r.reasonType)); - - const resultRows: HomepageRow[] = []; - - // Trending — split by media type for cleaner rows - const trendingByType = new Map(); - for (const r of trending) { - const t = r.item.type; - if (!trendingByType.has(t)) trendingByType.set(t, []); - trendingByType.get(t)!.push(r); - } - - const trendingLabels: Record = { - movie: { title: 'Trending Movies', subtitle: 'Popular films this week' }, - show: { title: 'Trending Shows', subtitle: 'Popular series this week' }, - video: { title: 'Trending Videos', subtitle: 'Popular videos this week' }, - book: { title: 'Trending Books', subtitle: 'Popular reads this week' }, - game: { title: 'Trending Games', subtitle: 'Popular games this week' } - }; - - for (const [mediaType, recs] of trendingByType) { - if (recs.length < MIN_ROW_ITEMS) continue; - const label = trendingLabels[mediaType] ?? { title: `Trending ${mediaType}`, subtitle: `Popular ${mediaType} this week` }; - resultRows.push({ - id: `trending-${mediaType}`, - title: label.title, - subtitle: label.subtitle, - type: 'reason', - items: recs.slice(0, 20).map((r) => recToItem(r)) - }); - } - - // Single combined trending row as fallback if no type has enough items - if (resultRows.length === 0 && trending.length >= MIN_ROW_ITEMS) { - resultRows.push({ - id: 'trending', - title: 'Trending Now', - subtitle: 'Popular across Nexus right now', - type: 'reason', - items: trending.slice(0, 20).map((r) => recToItem(r)) - }); - } - - // From Friends - if (friends.length >= MIN_ROW_ITEMS) { - resultRows.push({ - id: 'friends', - title: 'From Friends', - subtitle: 'Shared & watched by people you follow', - type: 'reason', - items: friends.slice(0, 20).map((r) => { - const context = r.reasonType === 'friend_shared' - ? `Shared by ${r.basedOn?.[0] ?? 'a friend'}` - : `${r.basedOn?.[0] ?? 'A friend'} watched this`; - return recToItem(r, context); - }) - }); - } - - // Right Now (time-aware) - if (timeAware.length >= MIN_ROW_ITEMS) { - resultRows.push({ - id: 'time-aware', - title: 'Perfect for Right Now', - subtitle: 'Based on what you usually watch at this time', - type: 'reason', - items: timeAware.slice(0, 20).map((r) => recToItem(r)) - }); - } - - // Recommended for You — split by type for variety - const recByType = new Map(); - for (const r of catchAll) { - const t = r.item.type; - if (!recByType.has(t)) recByType.set(t, []); - recByType.get(t)!.push(r); - } - - const recLabels: Record = { - movie: 'Recommended Movies', - show: 'Recommended Shows', - video: 'Recommended Videos', - book: 'Recommended Books', - game: 'Recommended Games' - }; - - for (const [mediaType, recs] of recByType) { - if (recs.length < MIN_ROW_ITEMS) continue; - resultRows.push({ - id: `recommended-${mediaType}`, - title: recLabels[mediaType] ?? 'Recommended for You', - type: 'reason', - items: recs.slice(0, 20).map((r) => recToItem(r)) - }); - } - - // Fallback combined row - if (!recByType.size && catchAll.length >= MIN_ROW_ITEMS) { - resultRows.push({ - id: 'recommended', - title: 'Recommended for You', - type: 'reason', - items: catchAll.slice(0, 20).map((r) => recToItem(r)) - }); - } - - // Genre rows — group by genre, ordered by user affinity - if (genreMatch.length > 0) { - const byGenre = new Map(); - for (const r of genreMatch) { - const genre = r.basedOn?.[0] ?? 'your favorites'; - if (!byGenre.has(genre)) byGenre.set(genre, []); - byGenre.get(genre)!.push(r); - } - - // Load genre affinity to order rows - const affinityRows = raw.prepare( - `SELECT genre, score FROM user_genre_affinity - WHERE user_id = ? AND media_type = 'all' - ORDER BY score DESC` - ).all(userId) as Array<{ genre: string; score: number }>; - const affinityOrder = affinityRows.map((r) => r.genre); - - // Sort genre keys by affinity order - const sortedGenres = Array.from(byGenre.keys()).sort((a, b) => { - const ai = affinityOrder.indexOf(a); - const bi = affinityOrder.indexOf(b); - return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi); - }); - - let rank = 1; - for (const genre of sortedGenres) { - const recs = byGenre.get(genre)!; - if (recs.length < MIN_ROW_ITEMS) continue; - const genreSlug = genre.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); - resultRows.push({ - id: `genre:${genreSlug}`, - title: genre, - subtitle: `Your #${rank} genre`, - type: 'genre', - items: recs.slice(0, 20).map((r) => recToItem(r)) - }); - rank++; - } - } - - return { hero, rows: resultRows, computedAt: Date.now() }; -} - -const HOMEPAGE_CACHE_TTL = 60 * 60 * 1000; // 60 min -const HOMEPAGE_CACHE_STALE_TTL = 6 * 60 * 60 * 1000; // serve stale for up to 6h while rebuilding - -/** Get cached homepage data for a user. Returns null on cache miss. */ -export async function getHomepageCache(userId: string): Promise { - return withStaleCache(`homepage:${userId}`, HOMEPAGE_CACHE_TTL, HOMEPAGE_CACHE_STALE_TTL, async () => { - const result = buildHomepageCache(userId); - // Don't cache null/empty results — let the eager build path fill the DB - if (!result || result.rows.length === 0) throw new Error('no-cache'); - return result; - }).catch(() => null); -} - -/** Invalidate homepage cache for a user (e.g., after profile change) */ -export function invalidateHomepageCache(userId: string) { - invalidate(`homepage:${userId}`); -} - -/** Default row order. - * - * Covers every row id this module or the homepage loader can produce. Rows - * not listed here still appear at the end (via applyRowOrder's tail append). - */ -export const DEFAULT_ROW_ORDER = [ - 'continue', - 'calendar', - 'trending-movie', 'trending-show', - 'friends', 'time-aware', - 'recommended-movie', 'recommended-show', - 'suggestions', - 'new', - 'upcoming-movies', 'upcoming-tv', - 'trending-book', 'trending-game', - 'recommended-book', 'recommended-game', - 'genre:*', - // Fallback combined rows - 'trending', 'recommended' -]; - -/** - * Apply user's row ordering preferences. - * 'genre:*' expands to all genre rows in their current (affinity) order. - * Continue Watching is always position 0 regardless of rowOrder. - */ -export function applyRowOrder(rows: HomepageRow[], rowOrder?: string[]): HomepageRow[] { - const order = rowOrder ?? DEFAULT_ROW_ORDER; - const rowMap = new Map(rows.map((r) => [r.id, r])); - const genreRows = rows.filter((r) => r.type === 'genre'); - const result: HomepageRow[] = []; - const placed = new Set(); - - for (const id of order) { - if (id === 'genre:*') { - // Expand to all genre rows not yet placed - for (const gr of genreRows) { - if (!placed.has(gr.id)) { - result.push(gr); - placed.add(gr.id); - } - } - } else if (id.startsWith('genre:') && !rowMap.has(id)) { - // Specific genre that might not exist — skip - continue; - } else { - const row = rowMap.get(id); - if (row && !placed.has(id)) { - result.push(row); - placed.add(id); - } - } - } - - // Append any rows not in the order - for (const row of rows) { - if (!placed.has(row.id)) { - result.push(row); - } - } - - return result; -} diff --git a/src/lib/server/media-sync.ts b/src/lib/server/media-sync.ts deleted file mode 100644 index 2f184865..00000000 --- a/src/lib/server/media-sync.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { getRawDb } from '$lib/db'; -import { getEnabledConfigs } from './services'; -import { registry } from '$lib/adapters/registry'; -import type { SyncItem } from '$lib/adapters/types'; - -// --------------------------------------------------------------------------- -// Media Items Sync -// -// Populates the media_items table from library data via adapter syncLibraryItems. -// This enables content-based and time-aware recommendation providers. -// Runs on startup and periodically via rec-scheduler. -// --------------------------------------------------------------------------- - -function upsertSyncItems(serviceId: string, serviceType: string, items: SyncItem[]) { - const raw = getRawDb(); - const stmt = raw.prepare( - `INSERT INTO media_items (id, source_id, service_id, type, title, sort_title, description, poster, backdrop, year, rating, genres, studios, duration, status, cached_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'available', datetime('now')) - ON CONFLICT(id) DO UPDATE SET - title = excluded.title, - description = excluded.description, - poster = excluded.poster, - backdrop = excluded.backdrop, - year = excluded.year, - rating = excluded.rating, - genres = excluded.genres, - studios = excluded.studios, - duration = excluded.duration, - cached_at = excluded.cached_at` - ); - - const tx = raw.transaction(() => { - for (const item of items) { - stmt.run( - `${item.sourceId}:${serviceId}`, - item.sourceId, - serviceId, - item.mediaType, - item.title, - item.sortTitle ?? null, - null, // description not on SyncItem (kept lean) - item.poster ?? null, - item.backdrop ?? null, - item.year ?? null, - item.rating ?? null, - item.genres ? JSON.stringify(item.genres) : null, - null, // studios not on SyncItem - item.duration ?? null - ); - } - }); - - tx(); -} - -/** Sync all library items from adapters that implement syncLibraryItems */ -export async function syncMediaItems(): Promise { - const configs = getEnabledConfigs(); - let totalSynced = 0; - - for (const config of configs) { - const adapter = registry.get(config.type); - if (!adapter?.syncLibraryItems) continue; - - try { - console.log(`[media-sync] Syncing from ${config.name ?? config.id}...`); - const items = await adapter.syncLibraryItems(config); - upsertSyncItems(config.id, config.type, items); - totalSynced += items.length; - console.log(`[media-sync] Synced ${items.length} items from ${config.name ?? config.id}`); - } catch (e) { - console.error(`[media-sync] ${config.type} error:`, e instanceof Error ? e.message : e); - } - } - - return totalSynced; -} - -/** Quick check: are media_items populated? */ -export function hasMediaItems(): boolean { - const raw = getRawDb(); - const row = raw.prepare('SELECT COUNT(*) as c FROM media_items').get() as { c: number }; - return row.c > 0; -} diff --git a/src/lib/server/music.ts b/src/lib/server/music.ts deleted file mode 100644 index c15eb10f..00000000 --- a/src/lib/server/music.ts +++ /dev/null @@ -1,559 +0,0 @@ -import { randomBytes } from 'crypto'; -import { and, eq, desc, sql } from 'drizzle-orm'; -import { getDb, schema } from '../db'; -import { getAlbums, getAlbumTracks, getArtists, getArtistAlbums, getInstantMix, getSongs } from '../adapters/jellyfin'; -import { getLidarrAlbums, getLidarrArtists, getLidarrWanted, getLidarrQueue } from '../adapters/lidarr'; -import { registry } from '../adapters/registry'; -import type { ServiceConfig, UserCredential, UnifiedMedia } from '../adapters/types'; -import { getConfigsForMediaType, getEnabledConfigs } from './services'; -import { getUserCredentialForService } from './auth'; -import { withCache } from './cache'; - -function genId(): string { - return randomBytes(16).toString('hex'); -} -function now(): number { - return Date.now(); -} - -// --------------------------------------------------------------------------- -// Service resolution helpers -// --------------------------------------------------------------------------- - -export function getJellyfinMusicConfigs(): ServiceConfig[] { - // Use registry to find all configs whose adapter supports 'music' and is a library - return getConfigsForMediaType('music').filter((c) => { - const adapter = registry.get(c.type); - return adapter?.isLibrary; - }); -} - -export function getLidarrConfig(): ServiceConfig | undefined { - // Find the first enabled config whose adapter supports 'music' but isn't a library - // (e.g. Lidarr provides music metadata/monitoring but Jellyfin is the library) - return getConfigsForMediaType('music').find((c) => { - const adapter = registry.get(c.type); - return adapter && !adapter.isLibrary; - }); -} - -function resolveJellyfinCred(config: ServiceConfig, userId?: string): UserCredential | undefined { - if (!userId) return undefined; - return getUserCredentialForService(userId, config.id) ?? undefined; -} - -// --------------------------------------------------------------------------- -// Albums -// --------------------------------------------------------------------------- - -export async function getMusicAlbums(userId: string, opts?: { - genre?: string; artistId?: string; sort?: string; limit?: number; offset?: number; serviceId?: string; -}) { - const configs = opts?.serviceId - ? getJellyfinMusicConfigs().filter((c) => c.id === opts.serviceId) - : getJellyfinMusicConfigs(); - - if (configs.length === 0) return { items: [], total: 0 }; - - const results = await Promise.allSettled( - configs.map((config) => { - const cred = resolveJellyfinCred(config, userId); - return getAlbums(config, cred, opts); - }) - ); - - const items = results.flatMap((r) => (r.status === 'fulfilled' ? r.value.items : [])); - const total = results.reduce((sum, r) => sum + (r.status === 'fulfilled' ? r.value.total : 0), 0); - - const enriched = await enrichAlbumsWithLidarr(items); - return { items: enriched, total }; -} - -export async function getMusicAlbumDetail(userId: string, albumId: string, serviceId: string) { - const config = getJellyfinMusicConfigs().find((c) => c.id === serviceId); - if (!config) return null; - - const cred = resolveJellyfinCred(config, userId); - const tracks = await getAlbumTracks(config, albumId, cred); - return { tracks }; -} - -// --------------------------------------------------------------------------- -// Songs -// --------------------------------------------------------------------------- - -export async function getMusicSongs(userId: string, opts?: { - sort?: string; limit?: number; offset?: number; search?: string; -}) { - const configs = getJellyfinMusicConfigs(); - if (configs.length === 0) return { items: [], total: 0 }; - - const results = await Promise.allSettled( - configs.map((config) => { - const cred = resolveJellyfinCred(config, userId); - return getSongs(config, cred, opts); - }) - ); - - const items = results.flatMap((r) => (r.status === 'fulfilled' ? r.value.items : [])); - const total = results.reduce((sum, r) => sum + (r.status === 'fulfilled' ? r.value.total : 0), 0); - - return { items, total }; -} - -// --------------------------------------------------------------------------- -// Artists -// --------------------------------------------------------------------------- - -export async function getMusicArtists(userId: string, opts?: { - sort?: string; limit?: number; offset?: number; serviceId?: string; -}) { - const configs = opts?.serviceId - ? getJellyfinMusicConfigs().filter((c) => c.id === opts.serviceId) - : getJellyfinMusicConfigs(); - - if (configs.length === 0) return { items: [], total: 0 }; - - const results = await Promise.allSettled( - configs.map((config) => { - const cred = resolveJellyfinCred(config, userId); - return getArtists(config, cred, opts); - }) - ); - - const items = results.flatMap((r) => (r.status === 'fulfilled' ? r.value.items : [])); - const total = results.reduce((sum, r) => sum + (r.status === 'fulfilled' ? r.value.total : 0), 0); - - return { items, total }; -} - -export async function getMusicArtistDetail(userId: string, artistId: string, serviceId: string) { - const configs = getJellyfinMusicConfigs(); - const config = configs.find((c) => c.id === serviceId) ?? configs[0]; - if (!config) return null; - - const cred = resolveJellyfinCred(config, userId); - - // Fetch artist info and albums in parallel - const [artistResult, albums] = await Promise.all([ - getArtists(config, cred, { limit: 1 }).then((r) => r.items).catch(() => []), - getArtistAlbums(config, artistId, cred) - ]); - - // Fetch the specific artist by fetching all and filtering by ID, - // or use the item endpoint for artist details - let artist: { id: string; name: string; imageUrl?: string; backdrop?: string; albumCount?: number; overview?: string; genres?: string[]; serviceId?: string } | null = null; - - // Try to get artist info from getArtists with the full list (cached) - // User-scoped: each user's `cred` may surface different library contents. - const allArtists = await withCache(`jf:artists:${config.id}:${userId}`, 120_000, () => - getArtists(config, cred, { limit: 500 }) - ); - const foundArtist = allArtists.items.find((a) => a.id === artistId); - if (foundArtist) { - artist = { ...foundArtist, serviceId: config.id }; - } else if (albums.length > 0) { - // Fallback: construct from album metadata - artist = { - id: artistId, - name: (albums[0]?.metadata?.artist as string) ?? 'Unknown Artist', - imageUrl: albums[0]?.metadata?.artistImageUrl as string | undefined, - albumCount: albums.length, - serviceId: config.id - }; - } - - if (!artist) return null; - - // Count total tracks across albums - const totalTracks = albums.reduce((sum, a) => sum + ((a.metadata?.userData as any)?.UnplayedItemCount ?? 0), 0); - (artist as any).trackCount = totalTracks; - - // Try Lidarr for richer artist data - const lidarrConfig = getLidarrConfig(); - let lidarrArtist = null; - if (lidarrConfig && albums.length > 0) { - const lidarrArtists = await withCache('lidarr:artists', 120_000, () => getLidarrArtists(lidarrConfig)); - const artistName = ((albums[0]?.metadata?.artist as string) ?? '').toLowerCase(); - lidarrArtist = lidarrArtists.find((a) => a.name.toLowerCase() === artistName) ?? null; - } - - return { artist, albums, lidarr: lidarrArtist }; -} - -export async function getArtistTopSongs(userId: string, artistId: string, serviceId: string, limit = 5): Promise { - const configs = getJellyfinMusicConfigs(); - const config = configs.find((c) => c.id === serviceId) ?? configs[0]; - if (!config) return []; - const cred = resolveJellyfinCred(config, userId); - if (!cred) return []; - - try { - const result = await getSongs(config, cred, { - artistId, - sort: 'PlayCount', - limit - }); - return result.items; - } catch (e) { - console.error('[music] Failed to fetch top songs:', e); - return []; - } -} - -// --------------------------------------------------------------------------- -// Instant Mix (recommendations) -// --------------------------------------------------------------------------- - -export async function getMusicInstantMix(userId: string, itemId: string, serviceId: string) { - const config = getJellyfinMusicConfigs().find((c) => c.id === serviceId); - if (!config) return []; - - const cred = resolveJellyfinCred(config, userId); - return getInstantMix(config, itemId, cred); -} - -// --------------------------------------------------------------------------- -// Lidarr Enrichment -// --------------------------------------------------------------------------- - -async function enrichAlbumsWithLidarr(albums: UnifiedMedia[]): Promise { - const lidarrConfig = getLidarrConfig(); - if (!lidarrConfig || albums.length === 0) return albums; - - try { - const lidarrAlbums = await withCache('lidarr:all-albums', 120_000, () => getLidarrAlbums(lidarrConfig)); - - const lidarrMap = new Map(); - for (const la of lidarrAlbums) { - lidarrMap.set(`${la.title.toLowerCase()}::${la.artistName.toLowerCase()}`, la); - } - - return albums.map((album) => { - const artist = ((album.metadata?.artist as string) ?? '').toLowerCase(); - const key = `${album.title.toLowerCase()}::${artist}`; - const match = lidarrMap.get(key); - if (!match) return album; - - return { - ...album, - metadata: { - ...album.metadata, - lidarr: { - monitored: match.monitored, - percentAvailable: match.percentAvailable, - missing: match.missingTracks - } - } - }; - }); - } catch { - return albums; - } -} - -export async function getMusicWanted(userId: string) { - const lidarrConfig = getLidarrConfig(); - if (!lidarrConfig) return { items: [], total: 0 }; - return withCache('lidarr:wanted', 60_000, () => getLidarrWanted(lidarrConfig)); -} - -export async function getMusicQueue() { - const lidarrConfig = getLidarrConfig(); - if (!lidarrConfig) return []; - return withCache('lidarr:queue', 30_000, () => getLidarrQueue(lidarrConfig)); -} - -// --------------------------------------------------------------------------- -// Liked Tracks (Nexus DB) -// --------------------------------------------------------------------------- - -export function getLikedTracks(userId: string) { - const db = getDb(); - return db - .select() - .from(schema.musicLikedTracks) - .where(eq(schema.musicLikedTracks.userId, userId)) - .orderBy(desc(schema.musicLikedTracks.createdAt)) - .all(); -} - -export function isTrackLiked(userId: string, trackId: string, serviceId: string): boolean { - const db = getDb(); - return !!db - .select() - .from(schema.musicLikedTracks) - .where(and( - eq(schema.musicLikedTracks.userId, userId), - eq(schema.musicLikedTracks.trackId, trackId), - eq(schema.musicLikedTracks.serviceId, serviceId) - )) - .get(); -} - -export function likeTrack(userId: string, trackId: string, serviceId: string): string { - const db = getDb(); - const existing = db - .select() - .from(schema.musicLikedTracks) - .where(and( - eq(schema.musicLikedTracks.userId, userId), - eq(schema.musicLikedTracks.trackId, trackId), - eq(schema.musicLikedTracks.serviceId, serviceId) - )) - .get(); - if (existing) return existing.id; - - const id = genId(); - db.insert(schema.musicLikedTracks).values({ - id, userId, trackId, serviceId, createdAt: now() - }).run(); - return id; -} - -export function unlikeTrack(userId: string, trackId: string, serviceId: string): boolean { - const db = getDb(); - const result = db - .delete(schema.musicLikedTracks) - .where(and( - eq(schema.musicLikedTracks.userId, userId), - eq(schema.musicLikedTracks.trackId, trackId), - eq(schema.musicLikedTracks.serviceId, serviceId) - )) - .run(); - return result.changes > 0; -} - -// --------------------------------------------------------------------------- -// Playlists (Nexus DB) -// --------------------------------------------------------------------------- - -export function getUserPlaylists(userId: string) { - const db = getDb(); - // Own playlists + collaborative playlists user has been invited to - const owned = db - .select() - .from(schema.musicPlaylists) - .where(eq(schema.musicPlaylists.userId, userId)) - .orderBy(desc(schema.musicPlaylists.updatedAt)) - .all(); - - const collabRows = db.all<{ playlist_id: string; role: string }>( - sql`SELECT playlist_id, role FROM playlist_collaborators WHERE user_id = ${userId}` - ); - const collabIds = collabRows.map((r) => r.playlist_id); - const collabPlaylists = collabIds.length > 0 - ? db.select().from(schema.musicPlaylists) - .where(sql`${schema.musicPlaylists.id} IN (${sql.join(collabIds.map(id => sql`${id}`), sql`,`)})`) - .orderBy(desc(schema.musicPlaylists.updatedAt)) - .all() - : []; - - const all = [...owned, ...collabPlaylists.filter((p) => !owned.some((o) => o.id === p.id))]; - - return all.map((p) => { - const trackCount = db.get<{ n: number }>( - sql`SELECT COUNT(*) as n FROM music_playlist_tracks WHERE playlist_id = ${p.id}` - ); - const collaborators = getPlaylistCollaborators(p.id); - const role = p.userId === userId ? 'owner' : (collabRows.find((r) => r.playlist_id === p.id)?.role ?? 'viewer'); - return { ...p, trackCount: trackCount?.n ?? 0, collaborators, role }; - }); -} - -export function createPlaylist(userId: string, name: string, description?: string, isCollaborative = false): string { - const db = getDb(); - const id = genId(); - const ts = now(); - db.insert(schema.musicPlaylists).values({ - id, userId, name, description: description ?? null, isCollaborative: isCollaborative ? 1 : 0, createdAt: ts, updatedAt: ts - }).run(); - return id; -} - -export function getPlaylist(playlistId: string, userId: string) { - const db = getDb(); - const playlist = db.select().from(schema.musicPlaylists) - .where(eq(schema.musicPlaylists.id, playlistId)) - .get(); - if (!playlist) return null; - - // Check access: owner, or collaborator - if (playlist.userId !== userId) { - const collab = db.select().from(schema.playlistCollaborators) - .where(and(eq(schema.playlistCollaborators.playlistId, playlistId), eq(schema.playlistCollaborators.userId, userId))) - .get(); - if (!collab) return null; - } - - const tracks = db.select().from(schema.musicPlaylistTracks) - .where(eq(schema.musicPlaylistTracks.playlistId, playlistId)) - .orderBy(schema.musicPlaylistTracks.position) - .all(); - - const collaborators = getPlaylistCollaborators(playlistId); - const role = playlist.userId === userId ? 'owner' : 'editor'; - - return { ...playlist, tracks, collaborators, role }; -} - -export function updatePlaylist(playlistId: string, userId: string, updates: { name?: string; description?: string }): boolean { - const db = getDb(); - const playlist = db.select().from(schema.musicPlaylists) - .where(and(eq(schema.musicPlaylists.id, playlistId), eq(schema.musicPlaylists.userId, userId))) - .get(); - if (!playlist) return false; - - const data: Record = { updatedAt: now() }; - if (updates.name !== undefined) data.name = updates.name; - if (updates.description !== undefined) data.description = updates.description; - - db.update(schema.musicPlaylists).set(data).where(eq(schema.musicPlaylists.id, playlistId)).run(); - return true; -} - -export function deletePlaylist(playlistId: string, userId: string): boolean { - const db = getDb(); - const playlist = db.select().from(schema.musicPlaylists) - .where(and(eq(schema.musicPlaylists.id, playlistId), eq(schema.musicPlaylists.userId, userId))) - .get(); - if (!playlist) return false; - - db.delete(schema.musicPlaylistTracks).where(eq(schema.musicPlaylistTracks.playlistId, playlistId)).run(); - db.delete(schema.musicPlaylists).where(eq(schema.musicPlaylists.id, playlistId)).run(); - return true; -} - -export function addTrackToPlaylist(playlistId: string, userId: string, trackId: string, serviceId: string): string | null { - const db = getDb(); - if (!canEditPlaylist(playlistId, userId)) return null; - - const maxPos = db.get<{ m: number }>( - sql`SELECT COALESCE(MAX(position), -1) as m FROM music_playlist_tracks WHERE playlist_id = ${playlistId}` - ); - - const id = genId(); - db.insert(schema.musicPlaylistTracks).values({ - id, playlistId, trackId, serviceId, position: (maxPos?.m ?? -1) + 1, addedAt: now() - }).run(); - - db.update(schema.musicPlaylists).set({ updatedAt: now() }).where(eq(schema.musicPlaylists.id, playlistId)).run(); - return id; -} - -export function removeTrackFromPlaylist(playlistId: string, userId: string, trackId: string): boolean { - const db = getDb(); - if (!canEditPlaylist(playlistId, userId)) return false; - - const result = db - .delete(schema.musicPlaylistTracks) - .where(and( - eq(schema.musicPlaylistTracks.playlistId, playlistId), - eq(schema.musicPlaylistTracks.trackId, trackId) - )) - .run(); - - if (result.changes > 0) { - db.update(schema.musicPlaylists).set({ updatedAt: now() }).where(eq(schema.musicPlaylists.id, playlistId)).run(); - } - return result.changes > 0; -} - -// --------------------------------------------------------------------------- -// Collaborative playlist helpers -// --------------------------------------------------------------------------- - -function canEditPlaylist(playlistId: string, userId: string): boolean { - const db = getDb(); - const playlist = db.select().from(schema.musicPlaylists) - .where(eq(schema.musicPlaylists.id, playlistId)) - .get(); - if (!playlist) return false; - if (playlist.userId === userId) return true; - const collab = db.select().from(schema.playlistCollaborators) - .where(and(eq(schema.playlistCollaborators.playlistId, playlistId), eq(schema.playlistCollaborators.userId, userId))) - .get(); - return collab?.role === 'editor'; -} - -function getPlaylistCollaborators(playlistId: string) { - const db = getDb(); - return db.select().from(schema.playlistCollaborators) - .where(eq(schema.playlistCollaborators.playlistId, playlistId)) - .all() - .map((c) => ({ userId: c.userId, role: c.role, addedAt: c.addedAt })); -} - -export function addCollaborator(playlistId: string, ownerId: string, collaboratorUserId: string, role: 'editor' | 'viewer' = 'editor'): boolean { - const db = getDb(); - const playlist = db.select().from(schema.musicPlaylists) - .where(and(eq(schema.musicPlaylists.id, playlistId), eq(schema.musicPlaylists.userId, ownerId))) - .get(); - if (!playlist) return false; - - // Don't add owner as collaborator - if (collaboratorUserId === ownerId) return false; - - // Check if already a collaborator - const existing = db.select().from(schema.playlistCollaborators) - .where(and(eq(schema.playlistCollaborators.playlistId, playlistId), eq(schema.playlistCollaborators.userId, collaboratorUserId))) - .get(); - if (existing) return true; - - db.insert(schema.playlistCollaborators).values({ - id: genId(), - playlistId, - userId: collaboratorUserId, - role, - addedAt: now() - }).run(); - - // Mark playlist as collaborative - db.update(schema.musicPlaylists).set({ isCollaborative: 1, updatedAt: now() }).where(eq(schema.musicPlaylists.id, playlistId)).run(); - return true; -} - -export function removeCollaborator(playlistId: string, ownerId: string, collaboratorUserId: string): boolean { - const db = getDb(); - const playlist = db.select().from(schema.musicPlaylists) - .where(and(eq(schema.musicPlaylists.id, playlistId), eq(schema.musicPlaylists.userId, ownerId))) - .get(); - if (!playlist) return false; - - db.delete(schema.playlistCollaborators) - .where(and(eq(schema.playlistCollaborators.playlistId, playlistId), eq(schema.playlistCollaborators.userId, collaboratorUserId))) - .run(); - - // If no collaborators left, mark as non-collaborative - const remaining = db.select().from(schema.playlistCollaborators) - .where(eq(schema.playlistCollaborators.playlistId, playlistId)) - .all(); - if (remaining.length === 0) { - db.update(schema.musicPlaylists).set({ isCollaborative: 0, updatedAt: now() }).where(eq(schema.musicPlaylists.id, playlistId)).run(); - } - return true; -} - -// --------------------------------------------------------------------------- -// Recently Played (from play_sessions) -// --------------------------------------------------------------------------- - -export function getRecentlyPlayed(userId: string, limit = 50): Array<{ mediaId: string; mediaTitle: string | null; serviceId: string; serviceType: string | null; timestamp: number }> { - const db = getDb(); - const rows = db.all<{ media_id: string; media_title: string | null; service_id: string; service_type: string | null; started_at: number }>( - sql`SELECT DISTINCT media_id, media_title, service_id, service_type, MAX(started_at) as started_at - FROM play_sessions - WHERE user_id = ${userId} - AND media_type = 'music' - GROUP BY media_id, service_id - ORDER BY started_at DESC - LIMIT ${limit}` - ); - return rows.map((r) => ({ - mediaId: r.media_id, - mediaTitle: r.media_title, - serviceId: r.service_id, - serviceType: r.service_type, - timestamp: r.started_at - })); -} diff --git a/src/lib/server/notifications.ts b/src/lib/server/notifications.ts index 9ab54a1f..89627e08 100644 --- a/src/lib/server/notifications.ts +++ b/src/lib/server/notifications.ts @@ -83,11 +83,11 @@ export function getNotifications(userId: string, opts?: { limit?: number; offset const actorNames = new Map(); if (actorIds.length > 0) { const actors = db - .select({ id: schema.users.id, displayName: schema.users.displayName }) + .select({ id: schema.users.id, displayName: schema.users.displayName, username: schema.users.username }) .from(schema.users) .where(inArray(schema.users.id, actorIds)) .all(); - for (const a of actors) actorNames.set(a.id, a.displayName); + for (const a of actors) actorNames.set(a.id, a.displayName ?? a.username); } return rows.map((row) => ({ diff --git a/src/lib/server/onboarding.ts b/src/lib/server/onboarding.ts deleted file mode 100644 index 767b8a58..00000000 --- a/src/lib/server/onboarding.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { getSetting, setSetting } from './auth'; -import { getEnabledConfigs } from './services'; -import { registry } from '$lib/adapters/registry'; -import type { OnboardingCategory } from '$lib/adapters/base'; - -export type ChecklistStatus = 'active' | 'snoozed' | 'dismissed'; - -export interface ChecklistState { - status: ChecklistStatus; - snoozedUntil: string | null; - completedCategories: OnboardingCategory[]; - totalOnboardable: number; -} - -export interface MissingCategory { - category: OnboardingCategory; - adapterName: string; - description: string; -} - -/** - * Get the current state of the Getting Started checklist. - * Cross-references the adapter registry with saved service configs. - */ -export function getChecklistState(): ChecklistState { - const status = (getSetting('onboarding_checklist_status') ?? 'active') as ChecklistStatus; - const snoozedUntil = getSetting('onboarding_checklist_snoozed_until'); - - const configs = getEnabledConfigs(); - const connectedTypes = new Set(configs.map((c) => c.type)); - - const onboardable = registry.onboardable(); - const categories = new Set(onboardable.map((a) => a.onboarding!.category)); - - const completedCategories: OnboardingCategory[] = []; - for (const cat of categories) { - const adaptersInCategory = onboardable.filter((a) => a.onboarding!.category === cat); - const hasConnected = adaptersInCategory.some((a) => connectedTypes.has(a.id)); - if (hasConnected) completedCategories.push(cat); - } - - return { - status, - snoozedUntil, - completedCategories, - totalOnboardable: categories.size, - }; -} - -/** - * Check if the checklist should be visible right now. - */ -export function isChecklistVisible(): boolean { - const state = getChecklistState(); - if (state.status === 'dismissed') return false; - if (state.status === 'snoozed' && state.snoozedUntil) { - return new Date() > new Date(state.snoozedUntil); - } - return true; -} - -/** - * Snooze the checklist for 7 days. - */ -export function snoozeChecklist(): void { - setSetting('onboarding_checklist_status', 'snoozed'); - const until = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); - setSetting('onboarding_checklist_snoozed_until', until); -} - -/** - * Permanently dismiss the checklist. - */ -export function dismissChecklist(): void { - setSetting('onboarding_checklist_status', 'dismissed'); -} - -/** - * Reset the checklist to active (used from admin settings). - */ -export function resetChecklist(): void { - setSetting('onboarding_checklist_status', 'active'); - setSetting('onboarding_checklist_snoozed_until', ''); -} - -/** - * Given a list of needed onboarding categories for a page, return - * the ones that have no connected services. - */ -export function getMissingCategories(needed: OnboardingCategory[]): MissingCategory[] { - const configs = getEnabledConfigs(); - const connectedTypes = new Set(configs.map((c) => c.type)); - - const missing: MissingCategory[] = []; - for (const cat of needed) { - const adapters = registry.byOnboardingCategory(cat); - const hasConnected = adapters.some((a) => connectedTypes.has(a.id)); - if (!hasConnected && adapters.length > 0) { - const first = adapters[0]; - missing.push({ - category: cat, - adapterName: first.displayName, - description: first.onboarding!.description, - }); - } - } - return missing; -} diff --git a/src/lib/server/platform-meta.ts b/src/lib/server/platform-meta.ts deleted file mode 100644 index cffd5afa..00000000 --- a/src/lib/server/platform-meta.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { RommPlatform } from '../adapters/romm'; - -export interface PlatformInfo { - id: number; - name: string; - fullName: string; - slug: string; - manufacturer: string; - year: number | null; - generation: number | null; - color: string; - romCount: number; - logo?: string; -} - -interface PlatformMeta { - fullName: string; - manufacturer: string; - year: number | null; - generation: number | null; - color: string; -} - -// Static enrichment data keyed by common platform slugs -const PLATFORM_DB: Record = { - // Nintendo - nes: { fullName: 'Nintendo Entertainment System', manufacturer: 'Nintendo', year: 1983, generation: 3, color: '#e60012' }, - snes: { fullName: 'Super Nintendo Entertainment System', manufacturer: 'Nintendo', year: 1990, generation: 4, color: '#7b5ea7' }, - n64: { fullName: 'Nintendo 64', manufacturer: 'Nintendo', year: 1996, generation: 5, color: '#009900' }, - gc: { fullName: 'Nintendo GameCube', manufacturer: 'Nintendo', year: 2001, generation: 6, color: '#6a0dad' }, - gamecube: { fullName: 'Nintendo GameCube', manufacturer: 'Nintendo', year: 2001, generation: 6, color: '#6a0dad' }, - wii: { fullName: 'Nintendo Wii', manufacturer: 'Nintendo', year: 2006, generation: 7, color: '#00a4dc' }, - wiiu: { fullName: 'Nintendo Wii U', manufacturer: 'Nintendo', year: 2012, generation: 8, color: '#009ac7' }, - switch: { fullName: 'Nintendo Switch', manufacturer: 'Nintendo', year: 2017, generation: 8, color: '#e60012' }, - gb: { fullName: 'Game Boy', manufacturer: 'Nintendo', year: 1989, generation: 4, color: '#8b8b73' }, - gbc: { fullName: 'Game Boy Color', manufacturer: 'Nintendo', year: 1998, generation: 5, color: '#6a0dad' }, - gba: { fullName: 'Game Boy Advance', manufacturer: 'Nintendo', year: 2001, generation: 6, color: '#5555ff' }, - nds: { fullName: 'Nintendo DS', manufacturer: 'Nintendo', year: 2004, generation: 7, color: '#ccc' }, - '3ds': { fullName: 'Nintendo 3DS', manufacturer: 'Nintendo', year: 2011, generation: 8, color: '#ce181e' }, - - // Sony - ps: { fullName: 'PlayStation', manufacturer: 'Sony', year: 1994, generation: 5, color: '#003087' }, - ps1: { fullName: 'PlayStation', manufacturer: 'Sony', year: 1994, generation: 5, color: '#003087' }, - psx: { fullName: 'PlayStation', manufacturer: 'Sony', year: 1994, generation: 5, color: '#003087' }, - ps2: { fullName: 'PlayStation 2', manufacturer: 'Sony', year: 2000, generation: 6, color: '#003087' }, - ps3: { fullName: 'PlayStation 3', manufacturer: 'Sony', year: 2006, generation: 7, color: '#003087' }, - ps4: { fullName: 'PlayStation 4', manufacturer: 'Sony', year: 2013, generation: 8, color: '#003087' }, - ps5: { fullName: 'PlayStation 5', manufacturer: 'Sony', year: 2020, generation: 9, color: '#003087' }, - psp: { fullName: 'PlayStation Portable', manufacturer: 'Sony', year: 2004, generation: 7, color: '#003087' }, - vita: { fullName: 'PlayStation Vita', manufacturer: 'Sony', year: 2011, generation: 8, color: '#003087' }, - - // Sega - 'master-system': { fullName: 'Sega Master System', manufacturer: 'Sega', year: 1985, generation: 3, color: '#0060a8' }, - genesis: { fullName: 'Sega Genesis', manufacturer: 'Sega', year: 1988, generation: 4, color: '#171717' }, - 'mega-drive': { fullName: 'Sega Mega Drive', manufacturer: 'Sega', year: 1988, generation: 4, color: '#171717' }, - saturn: { fullName: 'Sega Saturn', manufacturer: 'Sega', year: 1994, generation: 5, color: '#171717' }, - dreamcast: { fullName: 'Sega Dreamcast', manufacturer: 'Sega', year: 1998, generation: 6, color: '#ff6600' }, - 'game-gear': { fullName: 'Sega Game Gear', manufacturer: 'Sega', year: 1990, generation: 4, color: '#171717' }, - - // Microsoft - xbox: { fullName: 'Xbox', manufacturer: 'Microsoft', year: 2001, generation: 6, color: '#107c10' }, - xbox360: { fullName: 'Xbox 360', manufacturer: 'Microsoft', year: 2005, generation: 7, color: '#107c10' }, - xboxone: { fullName: 'Xbox One', manufacturer: 'Microsoft', year: 2013, generation: 8, color: '#107c10' }, - - // Atari - atari2600: { fullName: 'Atari 2600', manufacturer: 'Atari', year: 1977, generation: 2, color: '#c1272d' }, - atari7800: { fullName: 'Atari 7800', manufacturer: 'Atari', year: 1986, generation: 3, color: '#c1272d' }, - jaguar: { fullName: 'Atari Jaguar', manufacturer: 'Atari', year: 1993, generation: 5, color: '#c1272d' }, - lynx: { fullName: 'Atari Lynx', manufacturer: 'Atari', year: 1989, generation: 4, color: '#c1272d' }, - - // Other - 'neo-geo': { fullName: 'Neo Geo', manufacturer: 'SNK', year: 1990, generation: 4, color: '#ffd700' }, - 'neo-geo-pocket': { fullName: 'Neo Geo Pocket', manufacturer: 'SNK', year: 1998, generation: 5, color: '#ffd700' }, - 'turbografx-16': { fullName: 'TurboGrafx-16', manufacturer: 'NEC', year: 1987, generation: 4, color: '#ff6600' }, - 'pc-engine': { fullName: 'PC Engine', manufacturer: 'NEC', year: 1987, generation: 4, color: '#ff6600' }, - '3do': { fullName: '3DO Interactive Multiplayer', manufacturer: 'The 3DO Company', year: 1993, generation: 5, color: '#cc0000' }, - arcade: { fullName: 'Arcade', manufacturer: 'Various', year: null, generation: null, color: '#f59e0b' }, - dos: { fullName: 'MS-DOS', manufacturer: 'Microsoft', year: 1981, generation: null, color: '#444' }, - pc: { fullName: 'PC', manufacturer: 'Various', year: null, generation: null, color: '#888' }, -}; - -export function enrichPlatform(platform: RommPlatform): PlatformInfo { - const slug = platform.slug?.toLowerCase() ?? ''; - const meta = PLATFORM_DB[slug]; - - return { - id: platform.id, - name: platform.display_name, - fullName: meta?.fullName ?? platform.display_name, - slug: platform.slug, - manufacturer: meta?.manufacturer ?? 'Unknown', - year: meta?.year ?? null, - generation: meta?.generation ?? null, - color: meta?.color ?? '#7c6cf8', - romCount: platform.rom_count, - logo: platform.url_logo - }; -} diff --git a/src/lib/server/playback-sessions.ts b/src/lib/server/playback-sessions.ts new file mode 100644 index 00000000..fd4d1d21 --- /dev/null +++ b/src/lib/server/playback-sessions.ts @@ -0,0 +1,132 @@ +/** + * Playback session registry + reaper (Phase-0). + * + * Problem: /api/play/negotiate is stateless — it returns a stream URL and drops + * the adapter's `session.close()` handle, so when a browser tab closes nothing + * tells the backend to stop the transcode (ffmpeg orphans until the backend's + * own slow inactivity timeout). + * + * Fix (best-practice, matches Jellyfin/Plex/Emby): the player sends a keepalive + * every ~10s and a sendBeacon stop on tab close; a server-side reaper is the + * backstop — any session that goes silent for REAP_AFTER_MS is stopped via the + * adapter's close handle (Jellyfin Sessions/Playing/Stopped + transcode delete). + * + * The keepalive interval (10s) and reap threshold (30s = 3 missed pings) follow + * Jellyfin's progress cadence; we can reap faster than Jellyfin's ~60s because + * we control the client. The authoritative liveness signal is the heartbeat + * here; a future hardening can prefer the Rust proxy's last-byte-written time + * (more robust than a JS ping) — see the stream-proxy TODO. + */ + +export const KEEPALIVE_MS = 10_000; +const REAP_AFTER_MS = 30_000; +const SWEEP_EVERY_MS = 10_000; + +interface PlaybackSession { + id: string; + userId: string; + /** Best-effort stop — maps to the adapter's session.close (backend stop). */ + stop: () => Promise; + lastSeen: number; + createdAt: number; + label: string; +} + +const sessions = new Map(); +let reaper: ReturnType | null = null; + +function now() { + return Date.now(); +} + +/** Max concurrent playback sessions per user. Each holds a real backend transcode + * + cred; without a cap, looping negotiate() pins unbounded ffmpeg jobs (DoS — + * adversarial review F2). On overflow we stop the user's OLDEST session. */ +const MAX_SESSIONS_PER_USER = 8; + +/** Register a live playback session and return its id. Idempotent per id. */ +export function registerSession(opts: { + id: string; + userId: string; + stop: () => Promise; + label?: string; +}): string { + // Evict the user's oldest session(s) over the cap before adding a new one. + const mine = [...sessions.values()] + .filter((s) => s.userId === opts.userId) + .sort((a, b) => a.createdAt - b.createdAt); + for (let i = 0; i <= mine.length - MAX_SESSIONS_PER_USER; i++) { + const victim = mine[i]; + sessions.delete(victim.id); + console.warn(`[playback-sessions] session cap: evicting "${victim.label}"`); + void victim.stop().catch(() => {}); + } + sessions.set(opts.id, { + id: opts.id, + userId: opts.userId, + stop: opts.stop, + lastSeen: now(), + createdAt: now(), + label: opts.label ?? opts.id + }); + ensureReaper(); + return opts.id; +} + +/** Heartbeat: refresh lastSeen. Returns false if the session/user doesn't match + * (so a stale or spoofed id can't keep someone else's session alive). */ +export function heartbeat(id: string, userId: string): boolean { + const s = sessions.get(id); + if (!s || s.userId !== userId) return false; + s.lastSeen = now(); + return true; +} + +/** Explicit stop (sendBeacon on tab close, or changeQuality teardown). Only the + * owning user may stop their session. */ +export async function stopSession(id: string, userId: string): Promise { + const s = sessions.get(id); + if (!s || s.userId !== userId) return false; + sessions.delete(id); + try { + await s.stop(); + } catch (e) { + console.warn(`[playback-sessions] stop("${s.label}") failed:`, e); + } + return true; +} + +/** Reaper: stop+drop any session silent for longer than REAP_AFTER_MS. */ +async function sweep(): Promise { + const cutoff = now() - REAP_AFTER_MS; + const dead = [...sessions.values()].filter((s) => s.lastSeen < cutoff); + for (const s of dead) { + sessions.delete(s.id); + console.log( + `[playback-sessions] reaping "${s.label}" (silent ${Math.round((now() - s.lastSeen) / 1000)}s)` + ); + try { + await s.stop(); + } catch (e) { + console.warn(`[playback-sessions] reap stop("${s.label}") failed:`, e); + } + } + if (sessions.size === 0 && reaper) { + clearInterval(reaper); + reaper = null; + } +} + +function ensureReaper() { + if (reaper) return; + reaper = setInterval(() => { + void sweep(); + }, SWEEP_EVERY_MS); + // Don't keep the process alive solely for the reaper. + if (typeof reaper === 'object' && 'unref' in reaper) (reaper as { unref(): void }).unref(); +} + +/** TEST/inspection: current live session count. */ +export function liveSessionCount(): number { + return sessions.size; +} diff --git a/src/lib/server/playback.ts b/src/lib/server/playback.ts deleted file mode 100644 index 45fcdfac..00000000 --- a/src/lib/server/playback.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { getServiceConfig } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { registry } from '$lib/adapters/registry'; -import type { PlaybackPlan, PlaybackSession, BrowserCaps } from '$lib/adapters/playback'; - -export async function negotiate( - serviceId: string, - itemId: string, - plan: PlaybackPlan, - caps: BrowserCaps, - userId: string -): Promise { - const config = getServiceConfig(serviceId); - if (!config) throw new Error(`Service not found: ${serviceId}`); - - const adapter = registry.get(config.type); - if (!adapter?.negotiatePlayback) { - throw new Error(`Adapter ${config.type} does not support playback negotiation`); - } - - const userCred = getUserCredentialForService(userId, serviceId) ?? undefined; - - return adapter.negotiatePlayback( - config, - userCred, - { id: itemId, type: config.type }, - plan, - caps - ); -} diff --git a/src/lib/server/rec-scheduler.ts b/src/lib/server/rec-scheduler.ts deleted file mode 100644 index ef6a9cc0..00000000 --- a/src/lib/server/rec-scheduler.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { getRawDb } from '$lib/db'; -import { computeGenreAffinity, persistGenreAffinity } from './recommendations/providers/content-based'; -import { recRegistry } from './recommendations/registry'; -import { contentBasedProvider } from './recommendations/providers/content-based'; -import { streamyStatsProvider } from './recommendations/providers/streamystats'; -import { collaborativeProvider } from './recommendations/providers/collaborative'; -import { socialProvider } from './recommendations/providers/social'; -import { trendingProvider } from './recommendations/providers/trending'; -import { timeAwareProvider } from './recommendations/providers/time-aware'; -import { getRecommendations } from './recommendations/aggregator'; -import { invalidatePrefix, withCache } from './cache'; -import { buildHomepageCache, invalidateHomepageCache } from './homepage-cache'; -import { syncMediaItems, hasMediaItems } from './media-sync'; - -// --------------------------------------------------------------------------- -// Recommendation Scheduler -// -// Background job that periodically rebuilds genre affinity vectors and -// pre-computes recommendations for all active users. -// --------------------------------------------------------------------------- - -let schedulerInterval: ReturnType | null = null; -let tickCount = 0; -let initialized = false; - -/** Get user IDs with recent media events (active in last 30 days) */ -function getActiveUserIds(): string[] { - const raw = getRawDb(); - const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; - const rows = raw.prepare( - `SELECT DISTINCT user_id FROM play_sessions WHERE started_at > ? LIMIT 100` - ).all(cutoff) as Array<{ user_id: string }>; - return rows.map((r) => r.user_id); -} - -const MEDIA_TYPES = ['movie', 'show', 'episode', 'music', 'book', 'game']; - -/** Rebuild genre affinity vectors for a user */ -function rebuildAffinities(userId: string) { - for (const mediaType of MEDIA_TYPES) { - try { - const affinities = computeGenreAffinity(userId, mediaType); - persistGenreAffinity(userId, mediaType, affinities); - } catch (e) { - console.error(`[rec-scheduler] Affinity rebuild failed for ${userId}/${mediaType}:`, e); - } - } - - // Also compute an "all" affinity across all types - try { - const affinities = computeGenreAffinity(userId); - persistGenreAffinity(userId, 'all', affinities); - } catch (e) { - console.error(`[rec-scheduler] Affinity rebuild (all) failed for ${userId}:`, e); - } -} - -/** Pre-compute recommendations for a user across all browsable types */ -async function precomputeRecs(userId: string) { - for (const mediaType of ['movie', 'show', 'book', 'game']) { - try { - await getRecommendations(userId, mediaType, 30); - } catch (e) { - console.error(`[rec-scheduler] Precompute failed for ${userId}/${mediaType}:`, e); - } - } -} - -function runScheduledRebuilds() { - tickCount++; - const userIds = getActiveUserIds(); - - // Every 24th tick (2 hours) — re-sync media items from Jellyfin - if (tickCount % 24 === 0) { - syncMediaItems().catch((e) => - console.error('[rec-scheduler] Periodic media sync error:', e) - ); - } - - for (const userId of userIds) { - try { - // Every 6th tick (30 min) — rebuild genre affinity vectors - if (tickCount % 6 === 0) { - rebuildAffinities(userId); - } - - // Every 12th tick (60 min) — pre-compute recommendations, then build homepage cache - if (tickCount % 12 === 0) { - invalidatePrefix(`rec-rows:${userId}`); - invalidateHomepageCache(userId); - precomputeRecs(userId).then(() => { - const cache = buildHomepageCache(userId); - if (cache) { - withCache(`homepage:${userId}`, 60 * 60 * 1000, async () => cache); - } - console.log(`[rec-scheduler] Homepage cache built for ${userId}`); - }).catch((e) => - console.error(`[rec-scheduler] Precompute error for ${userId}:`, e) - ); - } - } catch (e) { - console.error(`[rec-scheduler] Error for user ${userId}:`, e); - } - } -} - -/** Initialize provider registry and start the scheduler */ -export function startRecScheduler() { - if (schedulerInterval) return; - - // Register built-in providers (only once) - if (!initialized) { - recRegistry.register(contentBasedProvider); - recRegistry.register(streamyStatsProvider); - recRegistry.register(collaborativeProvider); - recRegistry.register(socialProvider); - recRegistry.register(trendingProvider); - recRegistry.register(timeAwareProvider); - initialized = true; - } - - console.log('[rec-scheduler] Starting recommendation scheduler (5min interval)'); - schedulerInterval = setInterval(runScheduledRebuilds, 5 * 60 * 1000); - - // On startup: sync media items → build affinities → precompute recs - // Runs after a short delay to let the app finish booting - setTimeout(async () => { - console.log('[rec-scheduler] Starting initial data pipeline...'); - try { - // Step 1: Populate media_items from Jellyfin if empty - if (!hasMediaItems()) { - console.log('[rec-scheduler] media_items empty — running initial sync'); - await syncMediaItems(); - } else { - console.log('[rec-scheduler] media_items already populated'); - } - - // Step 2: Build genre affinities for all active users - const userIds = getActiveUserIds(); - console.log(`[rec-scheduler] Building affinities for ${userIds.length} users`); - for (const userId of userIds) { - rebuildAffinities(userId); - } - console.log('[rec-scheduler] Affinities built'); - - // Step 3: Precompute recommendations and build homepage cache - for (const userId of userIds) { - try { - invalidatePrefix(`rec-rows:${userId}`); - invalidateHomepageCache(userId); - await precomputeRecs(userId); - const cache = buildHomepageCache(userId); - if (cache) { - withCache(`homepage:${userId}`, 60 * 60 * 1000, async () => cache); - } - console.log(`[rec-scheduler] Initial homepage cache built for ${userId}`); - } catch (e) { - console.error(`[rec-scheduler] Initial precompute error for ${userId}:`, e); - } - } - } catch (e) { - console.error('[rec-scheduler] Startup sync error:', e); - } - }, 10_000); -} - -export function stopRecScheduler() { - if (schedulerInterval) { - clearInterval(schedulerInterval); - schedulerInterval = null; - } -} diff --git a/src/lib/server/recommendations/aggregator.ts b/src/lib/server/recommendations/aggregator.ts deleted file mode 100644 index 0503d0cb..00000000 --- a/src/lib/server/recommendations/aggregator.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { getRawDb } from '$lib/db'; -import type { DashboardRow } from '$lib/adapters/types'; -import { withCache } from '$lib/server/cache'; -import { recRegistry } from './registry'; -import type { ScoredRecommendation, RecommendationContext, RecProfileConfig } from './types'; -import { DEFAULT_PROFILE } from './types'; - -// --------------------------------------------------------------------------- -// Aggregator — orchestrates all providers, deduplicates, scores, caches -// --------------------------------------------------------------------------- - -const CACHE_TTL_MS = 30 * 60 * 1000; // 30 min - -/** Load user's default recommendation profile, or return global default */ -function loadUserProfile(userId: string): RecProfileConfig { - const raw = getRawDb(); - const row = raw.prepare( - `SELECT config FROM user_rec_profiles WHERE user_id = ? AND is_default = 1 LIMIT 1` - ).get(userId) as { config: string } | undefined; - - if (row?.config) { - try { - return { ...DEFAULT_PROFILE, ...JSON.parse(row.config) }; - } catch { /* fallback */ } - } - return DEFAULT_PROFILE; -} - -/** Load hidden item IDs for exclusion */ -function loadHiddenIds(userId: string): Set { - const raw = getRawDb(); - const rows = raw.prepare( - `SELECT media_id FROM user_hidden_items WHERE user_id = ?` - ).all(userId) as Array<{ media_id: string }>; - return new Set(rows.map((r) => r.media_id)); -} - -/** Check if cached results are still fresh */ -function getCachedResults( - userId: string, - mediaType: string | undefined, - profileId: string -): ScoredRecommendation[] | null { - const raw = getRawDb(); - const mt = mediaType ?? 'all'; - const row = raw.prepare( - `SELECT results, computed_at FROM recommendation_cache - WHERE user_id = ? AND profile_id = ? AND media_type = ? - LIMIT 1` - ).get(userId, profileId, mt) as { results: string; computed_at: number } | undefined; - - if (!row) return null; - if (Date.now() - row.computed_at > CACHE_TTL_MS) return null; - - try { - return JSON.parse(row.results); - } catch { - return null; - } -} - -/** Persist results to recommendation_cache */ -function cacheResults( - userId: string, - mediaType: string | undefined, - profileId: string, - results: ScoredRecommendation[] -) { - const raw = getRawDb(); - const mt = mediaType ?? 'all'; - raw.prepare( - `INSERT INTO recommendation_cache (user_id, profile_id, provider, media_type, results, computed_at) - VALUES (?, ?, 'aggregator', ?, ?, ?) - ON CONFLICT(user_id, profile_id, provider, media_type) DO UPDATE SET - results = excluded.results, - computed_at = excluded.computed_at` - ).run(userId, profileId, mt, JSON.stringify(results), Date.now()); -} - -/** Deduplicate recommendations by sourceId, merging scores from multiple providers */ -function deduplicateAndMerge(recs: ScoredRecommendation[]): ScoredRecommendation[] { - const bySourceId = new Map(); - - for (const rec of recs) { - const key = rec.item.sourceId; - const existing = bySourceId.get(key); - if (existing) { - existing.push(rec); - } else { - bySourceId.set(key, [rec]); - } - } - - const merged: ScoredRecommendation[] = []; - for (const [, group] of bySourceId) { - if (group.length === 1) { - merged.push(group[0]); - continue; - } - - const totalScore = group.reduce((s, r) => s + r.score, 0); - const avgScore = totalScore / group.length; - const bestConfidence = Math.max(...group.map((r) => r.confidence)); - const basedOn = [...new Set(group.flatMap((r) => r.basedOn ?? []))]; - - const best = group.sort((a, b) => b.score - a.score)[0]; - - merged.push({ - item: best.item, - score: avgScore, - confidence: bestConfidence, - provider: group.map((r) => r.provider).join('+'), - reason: best.reason, - reasonType: best.reasonType, - basedOn - }); - } - - return merged; -} - -/** Apply novelty mixing: blend familiar high-score items with novel lower-score items */ -function applyNoveltyMixing( - recs: ScoredRecommendation[], - noveltyFactor: number, - limit: number -): ScoredRecommendation[] { - if (noveltyFactor <= 0 || recs.length <= limit) return recs.slice(0, limit); - - const midpoint = Math.floor(recs.length / 2); - const familiar = recs.slice(0, midpoint); - const novel = recs.slice(midpoint); - - const familiarCount = Math.round(limit * (1 - noveltyFactor)); - const novelCount = limit - familiarCount; - - return [ - ...familiar.slice(0, familiarCount), - ...novel.slice(0, novelCount) - ].sort((a, b) => b.score - a.score); -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/** Get unified recommendations for a user */ -export async function getRecommendations( - userId: string, - mediaType?: string, - limit = 20, - profileOverride?: RecProfileConfig -): Promise { - const profile = profileOverride ?? loadUserProfile(userId); - const profileId = 'default'; - - // Check cache - const cached = getCachedResults(userId, mediaType, profileId); - if (cached) return cached.slice(0, limit); - - // Build context - const hiddenIds = loadHiddenIds(userId); - const now = new Date(); - const ctx: RecommendationContext = { - userId, - mediaType, - limit: limit * 3, - profile, - excludeIds: hiddenIds, - timeOfDay: now.getHours(), - dayOfWeek: now.getDay() - }; - - // Dispatch to all active providers in parallel - const providers = recRegistry.active(ctx); - console.log(`[rec-agg] Active providers for ${mediaType ?? 'all'}: ${providers.map(p => p.id).join(', ') || 'NONE'} (registry has ${recRegistry.all().length} total)`); - if (providers.length === 0) return []; - - const providerResults = await Promise.allSettled( - providers.map(async (p) => { - const recs = await p.getRecommendations(ctx); - console.log(`[rec-agg] Provider ${p.id} returned ${recs.length} results for ${mediaType ?? 'all'}`); - const weight = profile.weights[p.category] ?? 0.5; - return recs.map((r) => ({ ...r, score: r.score * weight })); - }) - ); - - let allRecs = providerResults.flatMap((r) => { - if (r.status === 'rejected') console.error(`[rec-agg] Provider failed:`, r.reason); - return r.status === 'fulfilled' ? r.value : []; - }); - - allRecs = deduplicateAndMerge(allRecs); - - if (profile.genreBans?.length) { - allRecs = allRecs.filter((r) => { - const genres = r.item.genres ?? []; - return !profile.genreBans!.some((ban) => genres.includes(ban)); - }); - } - if (profile.genreBoosts) { - for (const rec of allRecs) { - for (const genre of rec.item.genres ?? []) { - const boost = profile.genreBoosts[genre]; - if (boost != null) rec.score *= boost; - } - } - } - - allRecs.sort((a, b) => b.score - a.score); - - const novelty = profile.noveltyFactor ?? 0.3; - allRecs = applyNoveltyMixing(allRecs, novelty, limit); - - cacheResults(userId, mediaType, profileId, allRecs); - - return allRecs; -} - -/** Produce homepage dashboard rows from the recommendation engine */ -export async function getRecommendationRows(userId: string): Promise { - const cacheKey = `rec-rows:${userId}`; - - return withCache(cacheKey, 300_000, async () => { - const rows: DashboardRow[] = []; - - const movieRecs = await getRecommendations(userId, 'movie', 24); - if (movieRecs.length > 0) { - rows.push({ - id: `for-you-movies`, - title: 'For You — Movies', - subtitle: 'Personalized picks based on your viewing history', - items: movieRecs.map((r) => ({ - ...r.item, - metadata: { - ...r.item.metadata, - recReason: r.reason, - recScore: r.score, - recProvider: r.provider - } - })) - }); - } - - const showRecs = await getRecommendations(userId, 'show', 24); - if (showRecs.length > 0) { - rows.push({ - id: `for-you-shows`, - title: 'For You — Shows', - subtitle: 'Personalized picks based on your viewing history', - items: showRecs.map((r) => ({ - ...r.item, - metadata: { - ...r.item.metadata, - recReason: r.reason, - recScore: r.score, - recProvider: r.provider - } - })) - }); - } - - return rows; - }); -} diff --git a/src/lib/server/recommendations/providers/collaborative.ts b/src/lib/server/recommendations/providers/collaborative.ts deleted file mode 100644 index 640011d3..00000000 --- a/src/lib/server/recommendations/providers/collaborative.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { getRawDb } from '$lib/db'; -import type { - RecommendationProvider, - RecommendationContext, - ScoredRecommendation, - ProviderCategory -} from '../types'; - -// --------------------------------------------------------------------------- -// Collaborative Filtering Provider -// --------------------------------------------------------------------------- - -interface UserSimilarity { - userId: string; - similarity: number; -} - -let similarityCache = new Map(); -const lastBuildPerUser = new Map(); -const SIMILARITY_TTL_MS = 2 * 60 * 60 * 1000; - -function getGenreVector(userId: string): Map { - const raw = getRawDb(); - const rows = raw.prepare( - `SELECT genre, score FROM user_genre_affinity WHERE user_id = ? AND media_type = 'all'` - ).all(userId) as Array<{ genre: string; score: number }>; - return new Map(rows.map((r) => [r.genre, r.score])); -} - -function getWatchedSet(userId: string): Set { - const raw = getRawDb(); - const rows = raw.prepare( - `SELECT DISTINCT media_id FROM play_sessions WHERE user_id = ?` - ).all(userId) as Array<{ media_id: string }>; - return new Set(rows.map((r) => r.media_id)); -} - -function cosineSimilarity(a: Map, b: Map): number { - let dot = 0, magA = 0, magB = 0; - const allGenres = new Set([...a.keys(), ...b.keys()]); - for (const g of allGenres) { - const va = a.get(g) ?? 0; - const vb = b.get(g) ?? 0; - dot += va * vb; - magA += va * va; - magB += vb * vb; - } - const denom = Math.sqrt(magA) * Math.sqrt(magB); - return denom === 0 ? 0 : dot / denom; -} - -function jaccardSimilarity(a: Set, b: Set): number { - let intersection = 0; - for (const item of a) { - if (b.has(item)) intersection++; - } - const union = a.size + b.size - intersection; - return union === 0 ? 0 : intersection / union; -} - -function getEligibleUserIds(minEvents: number): string[] { - const raw = getRawDb(); - const rows = raw.prepare( - `SELECT user_id, COUNT(*) as c FROM play_sessions - GROUP BY user_id HAVING c >= ?` - ).all(minEvents) as Array<{ user_id: string; c: number }>; - return rows.map((r) => r.user_id); -} - -function buildSimilarityForUser(targetId: string, allUserIds: string[]): UserSimilarity[] { - const targetGenres = getGenreVector(targetId); - const targetWatched = getWatchedSet(targetId); - const similarities: UserSimilarity[] = []; - - for (const otherId of allUserIds) { - if (otherId === targetId) continue; - const genreSim = cosineSimilarity(targetGenres, getGenreVector(otherId)); - const watchSim = jaccardSimilarity(targetWatched, getWatchedSet(otherId)); - const combined = 0.6 * genreSim + 0.4 * watchSim; - if (combined >= 0.3) { - similarities.push({ userId: otherId, similarity: combined }); - } - } - - return similarities.sort((a, b) => b.similarity - a.similarity).slice(0, 10); -} - -function ensureSimilarityMatrix(targetId: string) { - const lastBuild = lastBuildPerUser.get(targetId) ?? 0; - if (Date.now() - lastBuild < SIMILARITY_TTL_MS && similarityCache.has(targetId)) return; - const allUserIds = getEligibleUserIds(10); - if (allUserIds.length < 3) return; - similarityCache.set(targetId, buildSimilarityForUser(targetId, allUserIds)); - lastBuildPerUser.set(targetId, Date.now()); -} - -export const collaborativeProvider: RecommendationProvider = { - id: 'collaborative', - displayName: 'People Like You', - category: 'collaborative' as ProviderCategory, - - isReady(ctx: RecommendationContext): boolean { - const eligible = getEligibleUserIds(10); - return eligible.length >= 3 && eligible.includes(ctx.userId); - }, - - async getRecommendations(ctx: RecommendationContext): Promise { - const raw = getRawDb(); - ensureSimilarityMatrix(ctx.userId); - - const neighbors = similarityCache.get(ctx.userId); - if (!neighbors || neighbors.length === 0) return []; - - const targetWatched = getWatchedSet(ctx.userId); - - // First pass: collect candidate (item, similarity) pairs across all neighbors. - type Candidate = { - media_id: string; - media_type: string; - media_title: string | null; - media_year: number | null; - media_genres: string | null; - similarity: number; - }; - const candidates: Candidate[] = []; - const seen = new Set(); - - for (const neighbor of neighbors) { - const typeFilter = ctx.mediaType ? `AND media_type = ?` : ''; - const params: (string | number)[] = [neighbor.userId]; - if (ctx.mediaType) params.push(ctx.mediaType); - - const items = raw.prepare( - `SELECT DISTINCT media_id, media_type, media_title, NULL as media_year, media_genres - FROM play_sessions - WHERE user_id = ? AND completed = 1 - ${typeFilter} - ORDER BY started_at DESC - LIMIT 50` - ).all(...params) as Array<{ - media_id: string; - media_type: string; - media_title: string | null; - media_year: number | null; - media_genres: string | null; - }>; - - for (const item of items) { - if (targetWatched.has(item.media_id) || ctx.excludeIds.has(item.media_id) || seen.has(item.media_id)) continue; - seen.add(item.media_id); - - let genres: string[] = []; - try { genres = item.media_genres ? JSON.parse(item.media_genres) : []; } catch { /* */ } - if (ctx.profile.genreBans?.some((ban) => genres.includes(ban))) continue; - - candidates.push({ ...item, similarity: neighbor.similarity }); - } - } - - // Batch media_items lookup across all candidates (was 1 query per candidate). - const cachedMap = new Map(); - if (candidates.length > 0) { - const ids = candidates.map((c) => c.media_id); - const placeholders = ids.map(() => '?').join(','); - const rows = raw.prepare( - `SELECT * FROM media_items WHERE source_id IN (${placeholders})` - ).all(...ids) as any[]; - for (const row of rows) { - if (!cachedMap.has(row.source_id)) cachedMap.set(row.source_id, row); - } - } - - const results: ScoredRecommendation[] = []; - for (const item of candidates) { - let genres: string[] = []; - try { genres = item.media_genres ? JSON.parse(item.media_genres) : []; } catch { /* */ } - - const cached = cachedMap.get(item.media_id); - const score = item.similarity * 0.8; - - results.push({ - item: cached - ? { - id: cached.id, - sourceId: cached.source_id, - serviceId: cached.service_id, - serviceType: 'jellyfin', - type: cached.type, - title: cached.title, - description: cached.description ?? undefined, - poster: cached.poster ?? undefined, - backdrop: cached.backdrop ?? undefined, - year: cached.year ?? undefined, - rating: cached.rating ?? undefined, - genres: cached.genres ? JSON.parse(cached.genres) : genres, - duration: cached.duration ?? undefined, - status: cached.status as any - } - : { - id: `${item.media_id}:collab`, - sourceId: item.media_id, - serviceId: '', - serviceType: 'unknown', - type: item.media_type as any, - title: item.media_title ?? 'Unknown', - year: item.media_year ?? undefined, - genres - }, - score, - confidence: Math.min(item.similarity, 1), - provider: 'collaborative', - reason: `Popular with viewers who share your taste`, - reasonType: 'similar_users' - }); - } - - return results.sort((a, b) => b.score - a.score).slice(0, ctx.limit); - } -}; diff --git a/src/lib/server/recommendations/providers/content-based.ts b/src/lib/server/recommendations/providers/content-based.ts deleted file mode 100644 index 4ef08445..00000000 --- a/src/lib/server/recommendations/providers/content-based.ts +++ /dev/null @@ -1,349 +0,0 @@ -import { getDb, getRawDb } from '$lib/db'; -import type { - RecommendationProvider, - RecommendationContext, - ScoredRecommendation, - ProviderCategory -} from '../types'; - -// --------------------------------------------------------------------------- -// Content-Based Recommendation Provider -// -// Uses genre affinity vectors computed from play_sessions to score candidate -// items the user hasn't consumed yet. -// --------------------------------------------------------------------------- - -interface GenreAffinity { - genre: string; - score: number; -} - -/** Compute genre affinity vector for a user + media type */ -export function computeGenreAffinity( - userId: string, - mediaType?: string, - halfLifeDays = 30 -): GenreAffinity[] { - const raw = getRawDb(); - const now = Date.now(); - const ln2 = Math.LN2; - - // Gather play sessions - const psCond = [`user_id = ?`]; - const psParams: (string | number)[] = [userId]; - if (mediaType) { - psCond.push(`media_type = ?`); - psParams.push(mediaType); - } - - const sessionRows = raw.prepare( - `SELECT media_genres, duration_ms, progress, media_duration_ms, started_at, completed - FROM play_sessions - WHERE ${psCond.join(' AND ')} - ORDER BY started_at DESC - LIMIT 4000` - ).all(...psParams) as Array<{ - media_genres: string | null; - duration_ms: number | null; - progress: number | null; - media_duration_ms: number | null; - started_at: number; - completed: number | null; - }>; - - // Gather media actions (like, watchlist_add, complete, detail_view) - const actCond = [`user_id = ?`, `action_type IN ('complete', 'like', 'watchlist_add', 'detail_view')`]; - const actParams: (string | number)[] = [userId]; - if (mediaType) { - actCond.push(`media_type = ?`); - actParams.push(mediaType); - } - - const actionRows = raw.prepare( - `SELECT action_type, timestamp - FROM media_actions - WHERE ${actCond.join(' AND ')} - ORDER BY timestamp DESC - LIMIT 1000` - ).all(...actParams) as Array<{ - action_type: string; - timestamp: number; - }>; - - // Combine into a unified row format for scoring - const rows: Array<{ - media_genres: string | null; - event_type: string; - play_duration_ms: number | null; - progress: number | null; - media_duration_ms: number | null; - timestamp: number; - }> = []; - - for (const s of sessionRows) { - rows.push({ - media_genres: s.media_genres, - event_type: s.completed ? 'complete' : 'play_stop', - play_duration_ms: s.duration_ms, - progress: s.progress, - media_duration_ms: s.media_duration_ms, - timestamp: s.started_at - }); - } - - for (const a of actionRows) { - rows.push({ - media_genres: null, // actions don't carry genres; scored via social bonus only - event_type: a.action_type, - play_duration_ms: null, - progress: null, - media_duration_ms: null, - timestamp: a.timestamp - }); - } - - const genreScores = new Map(); - - for (const row of rows) { - if (!row.media_genres) continue; - - let genres: string[]; - try { - genres = JSON.parse(row.media_genres); - } catch { - continue; - } - if (!Array.isArray(genres) || genres.length === 0) continue; - - const daysSince = (now - row.timestamp) / 86400000; - const recencyDecay = Math.exp((-ln2 * daysSince) / halfLifeDays); - - let weight = (row.play_duration_ms ?? 0) / 3600000; - if (weight === 0) weight = 0.1; - - let completionBonus = 1.0; - if (row.event_type === 'complete') { - completionBonus = 1.5; - } else if ( - row.progress != null && - row.progress > 0.7 - ) { - completionBonus = 1.2; - } - - let socialBonus = 0; - if (row.event_type === 'like' || row.event_type === 'watchlist_add') { - socialBonus = 0.3; - } - - const eventScore = (weight * recencyDecay * completionBonus) + socialBonus; - const perGenre = eventScore / genres.length; - for (const genre of genres) { - genreScores.set(genre, (genreScores.get(genre) ?? 0) + perGenre); - } - } - - const maxScore = Math.max(...genreScores.values(), 0.001); - return Array.from(genreScores.entries()) - .map(([genre, score]) => ({ genre, score: score / maxScore })) - .sort((a, b) => b.score - a.score); -} - -/** Persist genre affinity vectors to user_genre_affinity table */ -export function persistGenreAffinity(userId: string, mediaType: string, affinities: GenreAffinity[]) { - const raw = getRawDb(); - const now = Date.now(); - raw.prepare(`DELETE FROM user_genre_affinity WHERE user_id = ? AND media_type = ?`).run(userId, mediaType); - - if (affinities.length === 0) return; - - const insert = raw.prepare( - `INSERT INTO user_genre_affinity (user_id, media_type, genre, score, updated_at) - VALUES (?, ?, ?, ?, ?)` - ); - for (const aff of affinities) { - insert.run(userId, mediaType, aff.genre, aff.score, now); - } -} - -/** Cosine similarity between a candidate's genres and user's affinity vector */ -function genreCosine(candidateGenres: string[], affinity: Map): number { - if (candidateGenres.length === 0 || affinity.size === 0) return 0; - - let dot = 0; - let candidateMag = 0; - - for (const g of candidateGenres) { - const a = affinity.get(g) ?? 0; - dot += a; - candidateMag += 1; - } - - let affinityMag = 0; - for (const v of affinity.values()) { - affinityMag += v * v; - } - affinityMag = Math.sqrt(affinityMag); - - const denom = Math.sqrt(candidateMag) * affinityMag; - if (denom === 0) return 0; - return dot / denom; -} - -/** Get IDs of items the user has already consumed */ -function getConsumedMediaIds(userId: string, mediaType?: string): Set { - const raw = getRawDb(); - const conditions = [`user_id = ?`]; - const params: (string | number)[] = [userId]; - if (mediaType) { - conditions.push(`media_type = ?`); - params.push(mediaType); - } - - const rows = raw.prepare( - `SELECT DISTINCT media_id FROM play_sessions WHERE ${conditions.join(' AND ')}` - ).all(...params) as Array<{ media_id: string }>; - - return new Set(rows.map((r) => r.media_id)); -} - -export const contentBasedProvider: RecommendationProvider = { - id: 'content-based', - displayName: 'Content Match', - category: 'contentBased' as ProviderCategory, - - isReady(ctx: RecommendationContext): boolean { - const raw = getRawDb(); - const count = raw.prepare( - `SELECT COUNT(*) as c FROM play_sessions WHERE user_id = ? LIMIT 1` - ).get(ctx.userId) as { c: number } | undefined; - return (count?.c ?? 0) > 0; - }, - - async getRecommendations(ctx: RecommendationContext): Promise { - const raw = getRawDb(); - const halfLife = ctx.profile.recencyHalfLifeDays ?? 30; - - const affinities = computeGenreAffinity(ctx.userId, ctx.mediaType, halfLife); - if (affinities.length === 0) return []; - - const affinityMap = new Map(affinities.map((a) => [a.genre, a.score])); - const consumed = getConsumedMediaIds(ctx.userId, ctx.mediaType); - - const typeFilter = ctx.mediaType ? `AND type = ?` : ''; - const params: (string | number)[] = []; - if (ctx.mediaType) params.push(ctx.mediaType); - - const candidates = raw.prepare( - `SELECT id, source_id, service_id, type, title, description, poster, backdrop, - year, rating, genres, studios, duration, status, metadata - FROM media_items - WHERE 1=1 ${typeFilter} - ORDER BY cached_at DESC - LIMIT 500` - ).all(...params) as Array<{ - id: string; - source_id: string; - service_id: string; - type: string; - title: string; - description: string | null; - poster: string | null; - backdrop: string | null; - year: number | null; - rating: number | null; - genres: string | null; - studios: string | null; - duration: number | null; - status: string | null; - metadata: string | null; - }>; - - // Compute preferred year from recent events - const yearParams: (string | number)[] = [ctx.userId]; - if (ctx.mediaType) yearParams.push(ctx.mediaType); - const recentYears = raw.prepare( - `SELECT media_year FROM play_sessions - WHERE user_id = ? AND media_year IS NOT NULL - ${ctx.mediaType ? 'AND media_type = ?' : ''} - ORDER BY started_at DESC LIMIT 50` - ).all(...yearParams) as Array<{ media_year: number }>; - const avgYear = - recentYears.length > 0 - ? recentYears.reduce((s, r) => s + r.media_year, 0) / recentYears.length - : new Date().getFullYear(); - - const results: ScoredRecommendation[] = []; - - for (const c of candidates) { - if (consumed.has(c.source_id) || ctx.excludeIds.has(c.source_id) || ctx.excludeIds.has(c.id)) { - continue; - } - - if (ctx.profile.yearRange) { - if (c.year && ctx.profile.yearRange.min && c.year < ctx.profile.yearRange.min) continue; - if (c.year && ctx.profile.yearRange.max && c.year > ctx.profile.yearRange.max) continue; - } - - if (ctx.profile.minRating && c.rating && c.rating < ctx.profile.minRating) continue; - - let genres: string[] = []; - try { - genres = c.genres ? JSON.parse(c.genres) : []; - } catch { /* empty */ } - - if (ctx.profile.genreBans?.some((ban) => genres.includes(ban))) continue; - - const genreScore = genreCosine(genres, affinityMap); - const eraScore = c.year - ? Math.exp(-Math.pow(c.year - avgYear, 2) / 200) - : 0.5; - const ratingScore = c.rating ? c.rating / 10 : 0.5; - - let score = genreScore * 0.60 + eraScore * 0.15 + ratingScore * 0.10; - score += 0.15 * 0.5; // neutral studio score - - if (ctx.profile.genreBoosts) { - for (const genre of genres) { - const boost = ctx.profile.genreBoosts[genre]; - if (boost != null) score *= boost; - } - } - - if (score < 0.05) continue; - - const topGenre = genres - .map((g) => ({ g, s: affinityMap.get(g) ?? 0 })) - .sort((a, b) => b.s - a.s)[0]; - - results.push({ - item: { - id: c.id, - sourceId: c.source_id, - serviceId: c.service_id, - serviceType: 'jellyfin', - type: c.type as any, - title: c.title, - description: c.description ?? undefined, - poster: c.poster ?? undefined, - backdrop: c.backdrop ?? undefined, - year: c.year ?? undefined, - rating: c.rating ?? undefined, - genres, - duration: c.duration ?? undefined, - status: c.status as any - }, - score: Math.min(score, 1), - confidence: Math.min(affinities.length / 10, 1), - provider: 'content-based', - reason: topGenre - ? `Matches your interest in ${topGenre.g}` - : 'Based on your viewing patterns', - reasonType: 'genre_match', - basedOn: affinities.slice(0, 3).map((a) => a.genre) - }); - } - - return results.sort((a, b) => b.score - a.score).slice(0, ctx.limit * 2); - } -}; diff --git a/src/lib/server/recommendations/providers/social.ts b/src/lib/server/recommendations/providers/social.ts deleted file mode 100644 index 65fd2ccf..00000000 --- a/src/lib/server/recommendations/providers/social.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { getRawDb } from '$lib/db'; -import type { - RecommendationProvider, - RecommendationContext, - ScoredRecommendation, - ProviderCategory -} from '../types'; - -// --------------------------------------------------------------------------- -// Social Signals Provider -// --------------------------------------------------------------------------- - -function getFriendIds(userId: string): string[] { - const raw = getRawDb(); - const rows = raw.prepare( - `SELECT CASE WHEN user_id = ? THEN friend_id ELSE user_id END as fid - FROM friendships - WHERE (user_id = ? OR friend_id = ?) AND status = 'accepted'` - ).all(userId, userId, userId) as Array<{ fid: string }>; - return rows.map((r) => r.fid); -} - -export const socialProvider: RecommendationProvider = { - id: 'social', - displayName: 'From Friends', - category: 'social' as ProviderCategory, - - isReady(ctx: RecommendationContext): boolean { - return getFriendIds(ctx.userId).length > 0; - }, - - async getRecommendations(ctx: RecommendationContext): Promise { - const raw = getRawDb(); - const friendIds = getFriendIds(ctx.userId); - if (friendIds.length === 0) return []; - - const results: ScoredRecommendation[] = []; - const seen = new Set(); - - const consumed = new Set( - (raw.prepare(`SELECT DISTINCT media_id FROM play_sessions WHERE user_id = ?`).all(ctx.userId) as Array<{ media_id: string }>) - .map((r) => r.media_id) - ); - - const placeholders = friendIds.map(() => '?').join(','); - - // 1. Items shared TO the user (highest signal: 0.9) - const shared = raw.prepare( - `SELECT media_id, media_type, media_title, media_poster, from_user_id, service_id - FROM shared_items - WHERE to_user_id = ? - ORDER BY created_at DESC LIMIT 20` - ).all(ctx.userId) as Array<{ - media_id: string; - media_type: string; - media_title: string; - media_poster: string | null; - from_user_id: string; - service_id: string; - }>; - - for (const item of shared) { - if (consumed.has(item.media_id) || ctx.excludeIds.has(item.media_id) || seen.has(item.media_id)) continue; - if (ctx.mediaType && item.media_type !== ctx.mediaType) continue; - seen.add(item.media_id); - - results.push({ - item: { - id: `${item.media_id}:${item.service_id}`, - sourceId: item.media_id, - serviceId: item.service_id, - serviceType: 'social', - type: item.media_type as any, - title: item.media_title, - poster: item.media_poster ?? undefined - }, - score: 0.9, - confidence: 0.85, - provider: 'social', - reason: `Shared with you by a friend`, - reasonType: 'friend_shared' - }); - } - - // 2. Items friends completed + liked recently (signal: 0.5) - const typeFilter = ctx.mediaType ? `AND me.media_type = ?` : ''; - const friendEventParams: (string | number)[] = [...friendIds]; - if (ctx.mediaType) friendEventParams.push(ctx.mediaType); - friendEventParams.push(Date.now() - 14 * 24 * 60 * 60 * 1000); - - const friendLiked = raw.prepare( - `SELECT DISTINCT ma.media_id, ma.media_type, ma.media_title, NULL as media_year, NULL as media_genres - FROM media_actions ma - WHERE ma.user_id IN (${placeholders}) - AND ma.action_type IN ('complete', 'like', 'watchlist_add') - ${typeFilter.replace('me.', 'ma.')} - AND ma.timestamp > ? - ORDER BY ma.timestamp DESC - LIMIT 50` - ).all(...friendEventParams) as Array<{ - media_id: string; - media_type: string; - media_title: string | null; - media_year: number | null; - media_genres: string | null; - }>; - - // Batch media_items lookups for the whole friendLiked set (was N queries). - const friendLikedIds = friendLiked.map((i) => i.media_id); - const cachedMap = new Map(); - if (friendLikedIds.length > 0) { - const mediaPlaceholders = friendLikedIds.map(() => '?').join(','); - const cachedRows = raw.prepare( - `SELECT * FROM media_items WHERE source_id IN (${mediaPlaceholders})` - ).all(...friendLikedIds) as any[]; - // First row per source_id wins (matches LIMIT 1 behavior). - for (const row of cachedRows) { - if (!cachedMap.has(row.source_id)) cachedMap.set(row.source_id, row); - } - } - - for (const item of friendLiked) { - if (consumed.has(item.media_id) || ctx.excludeIds.has(item.media_id) || seen.has(item.media_id)) continue; - seen.add(item.media_id); - - let genres: string[] = []; - try { genres = item.media_genres ? JSON.parse(item.media_genres) : []; } catch { /* */ } - if (ctx.profile.genreBans?.some((ban) => genres.includes(ban))) continue; - - const cached = cachedMap.get(item.media_id); - - results.push({ - item: cached - ? { - id: cached.id, - sourceId: cached.source_id, - serviceId: cached.service_id, - serviceType: 'jellyfin', - type: cached.type, - title: cached.title, - description: cached.description ?? undefined, - poster: cached.poster ?? undefined, - backdrop: cached.backdrop ?? undefined, - year: cached.year ?? undefined, - rating: cached.rating ?? undefined, - genres: cached.genres ? JSON.parse(cached.genres) : genres, - duration: cached.duration ?? undefined - } - : { - id: `${item.media_id}:social`, - sourceId: item.media_id, - serviceId: '', - serviceType: 'unknown', - type: item.media_type as any, - title: item.media_title ?? 'Unknown', - year: item.media_year ?? undefined, - genres - }, - score: 0.5, - confidence: 0.6, - provider: 'social', - reason: `Liked by your friends`, - reasonType: 'friend_watched' - }); - } - - // 3. Items in friends' collections (signal: 0.6) - const friendCollections = raw.prepare( - `SELECT ci.media_id, ci.media_type, ci.media_title, ci.media_poster, ci.service_id - FROM collection_items ci - JOIN collections c ON c.id = ci.collection_id - WHERE c.creator_id IN (${placeholders}) AND c.visibility IN ('friends', 'public') - ORDER BY ci.created_at DESC LIMIT 30` - ).all(...friendIds) as Array<{ - media_id: string; - media_type: string; - media_title: string; - media_poster: string | null; - service_id: string; - }>; - - for (const item of friendCollections) { - if (consumed.has(item.media_id) || ctx.excludeIds.has(item.media_id) || seen.has(item.media_id)) continue; - if (ctx.mediaType && item.media_type !== ctx.mediaType) continue; - seen.add(item.media_id); - - results.push({ - item: { - id: `${item.media_id}:${item.service_id}`, - sourceId: item.media_id, - serviceId: item.service_id, - serviceType: 'social', - type: item.media_type as any, - title: item.media_title, - poster: item.media_poster ?? undefined - }, - score: 0.6, - confidence: 0.5, - provider: 'social', - reason: `In a friend's collection`, - reasonType: 'friend_shared' - }); - } - - return results.sort((a, b) => b.score - a.score).slice(0, ctx.limit); - } -}; diff --git a/src/lib/server/recommendations/providers/streamystats.ts b/src/lib/server/recommendations/providers/streamystats.ts deleted file mode 100644 index f11a1a90..00000000 --- a/src/lib/server/recommendations/providers/streamystats.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { getStreamyStatsRecommendations } from '$lib/adapters/streamystats'; -import { registry } from '$lib/adapters/registry'; -import { getEnabledConfigs } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import type { - RecommendationProvider, - RecommendationContext, - ScoredRecommendation, - ProviderCategory -} from '../types'; - -// --------------------------------------------------------------------------- -// StreamyStats Provider — wraps existing StreamyStats recommendation API -// --------------------------------------------------------------------------- - -export const streamyStatsProvider: RecommendationProvider = { - id: 'streamystats', - displayName: 'StreamyStats', - category: 'external' as ProviderCategory, - requiresService: 'streamystats', - - isReady(ctx: RecommendationContext): boolean { - const ssConfigs = getEnabledConfigs().filter((c) => c.type === 'streamystats'); - if (ssConfigs.length === 0) return false; - - // Resolve auth adapter via registry (e.g. StreamyStats authenticates via Jellyfin) - const ssAdapter = registry.get('streamystats'); - const authAdapterId = ssAdapter?.authVia; - const authConfig = authAdapterId ? getEnabledConfigs().find((c) => c.type === authAdapterId) : undefined; - if (!authConfig) return false; - - const cred = getUserCredentialForService(ctx.userId, authConfig.id); - return !!cred?.accessToken; - }, - - async getRecommendations(ctx: RecommendationContext): Promise { - const ssConfigs = getEnabledConfigs().filter((c) => c.type === 'streamystats'); - const ssAdapter = registry.get('streamystats'); - const authAdapterId = ssAdapter?.authVia; - const authConfig = authAdapterId ? getEnabledConfigs().find((c) => c.type === authAdapterId) : undefined; - if (!authConfig || ssConfigs.length === 0) return []; - - const cred = getUserCredentialForService(ctx.userId, authConfig.id); - if (!cred?.accessToken) return []; - - const results: ScoredRecommendation[] = []; - - for (const config of ssConfigs) { - // Map mediaType to SS type - const ssTypes: Array<'Movie' | 'Series'> = []; - if (!ctx.mediaType || ctx.mediaType === 'movie') ssTypes.push('Movie'); - if (!ctx.mediaType || ctx.mediaType === 'show') ssTypes.push('Series'); - - for (const ssType of ssTypes) { - try { - const items = await getStreamyStatsRecommendations(config, ssType, cred, ctx.limit); - - for (const item of items) { - if (ctx.excludeIds.has(item.sourceId) || ctx.excludeIds.has(item.id)) continue; - - const reason = (item.metadata?.reason as string) ?? 'Recommended by StreamyStats'; - const similarity = (item.metadata?.similarity as number) ?? 0.5; - - results.push({ - item, - score: similarity, - confidence: 0.7, // external service — moderate confidence - provider: 'streamystats', - reason, - reasonType: 'external', - basedOn: [] - }); - } - } catch (e) { - console.error(`[rec:streamystats] Error fetching ${ssType}:`, e instanceof Error ? e.message : e); - } - } - } - - return results; - } -}; diff --git a/src/lib/server/recommendations/providers/time-aware.ts b/src/lib/server/recommendations/providers/time-aware.ts deleted file mode 100644 index 7e04dd55..00000000 --- a/src/lib/server/recommendations/providers/time-aware.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { getRawDb } from '$lib/db'; -import type { - RecommendationProvider, - RecommendationContext, - ScoredRecommendation, - ProviderCategory -} from '../types'; - -// --------------------------------------------------------------------------- -// Time-Aware Provider (re-ranker) -// --------------------------------------------------------------------------- - -function buildTimeModel(userId: string): Map> { - const raw = getRawDb(); - const rows = raw.prepare( - `SELECT started_at as timestamp, media_genres FROM play_sessions - WHERE user_id = ? AND media_genres IS NOT NULL - ORDER BY started_at DESC LIMIT 2000` - ).all(userId) as Array<{ timestamp: number; media_genres: string }>; - - const model = new Map>(); - - for (const row of rows) { - const hour = new Date(row.timestamp).getHours(); - let genres: string[]; - try { genres = JSON.parse(row.media_genres); } catch { continue; } - - if (!model.has(hour)) model.set(hour, new Map()); - const hourMap = model.get(hour)!; - - for (const genre of genres) { - hourMap.set(genre, (hourMap.get(genre) ?? 0) + 1); - } - } - - return model; -} - -function timeRelevance( - genres: string[], - hour: number, - _isWeekend: boolean, - model: Map> -): number { - if (model.size === 0 || genres.length === 0) return 1.0; - - const hourMap = model.get(hour); - if (!hourMap) return 1.0; - - let matchCount = 0; - let totalCount = 0; - - for (const [, count] of hourMap) { - totalCount += count; - } - if (totalCount === 0) return 1.0; - - for (const genre of genres) { - matchCount += hourMap.get(genre) ?? 0; - } - - const ratio = matchCount / totalCount; - return 0.7 + ratio * 0.6; -} - -export const timeAwareProvider: RecommendationProvider = { - id: 'time-aware', - displayName: 'Right Time', - category: 'contentBased' as ProviderCategory, - - isReady(ctx: RecommendationContext): boolean { - const raw = getRawDb(); - const count = raw.prepare( - `SELECT COUNT(*) as c FROM play_sessions WHERE user_id = ? AND media_genres IS NOT NULL LIMIT 1` - ).get(ctx.userId) as { c: number } | undefined; - return (count?.c ?? 0) >= 20; - }, - - async getRecommendations(ctx: RecommendationContext): Promise { - const raw = getRawDb(); - const model = buildTimeModel(ctx.userId); - if (model.size === 0) return []; - - const hour = ctx.timeOfDay ?? new Date().getHours(); - const isWeekend = ctx.dayOfWeek != null ? (ctx.dayOfWeek === 0 || ctx.dayOfWeek === 6) : false; - - const hourMap = model.get(hour); - if (!hourMap || hourMap.size === 0) return []; - - const topGenres = Array.from(hourMap.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 3) - .map(([genre]) => genre); - - if (topGenres.length === 0) return []; - - const consumed = new Set( - (raw.prepare(`SELECT DISTINCT media_id FROM play_sessions WHERE user_id = ?`).all(ctx.userId) as Array<{ media_id: string }>) - .map((r) => r.media_id) - ); - - const typeFilter = ctx.mediaType ? `AND type = ?` : ''; - const params: (string | number)[] = []; - if (ctx.mediaType) params.push(ctx.mediaType); - - const candidates = raw.prepare( - `SELECT * FROM media_items WHERE genres IS NOT NULL ${typeFilter} - ORDER BY cached_at DESC LIMIT 200` - ).all(...params) as any[]; - - const results: ScoredRecommendation[] = []; - - for (const c of candidates) { - if (consumed.has(c.source_id) || ctx.excludeIds.has(c.source_id)) continue; - - let genres: string[]; - try { genres = JSON.parse(c.genres); } catch { continue; } - - const relevance = timeRelevance(genres, hour, isWeekend, model); - if (relevance < 0.85) continue; - - const matchingGenres = genres.filter((g: string) => topGenres.includes(g)); - if (matchingGenres.length === 0) continue; - - results.push({ - item: { - id: c.id, - sourceId: c.source_id, - serviceId: c.service_id, - serviceType: 'jellyfin', - type: c.type, - title: c.title, - description: c.description ?? undefined, - poster: c.poster ?? undefined, - backdrop: c.backdrop ?? undefined, - year: c.year ?? undefined, - rating: c.rating ?? undefined, - genres, - duration: c.duration ?? undefined - }, - score: relevance * 0.5, - confidence: 0.4, - provider: 'time-aware', - reason: `Great for ${hour >= 20 || hour < 5 ? 'tonight' : hour >= 12 ? 'this afternoon' : 'this morning'}`, - reasonType: 'time_pattern' - }); - } - - return results.sort((a, b) => b.score - a.score).slice(0, ctx.limit); - } -}; diff --git a/src/lib/server/recommendations/providers/trending.ts b/src/lib/server/recommendations/providers/trending.ts deleted file mode 100644 index 19760b2c..00000000 --- a/src/lib/server/recommendations/providers/trending.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { getRawDb } from '$lib/db'; -import { withCache } from '$lib/server/cache'; -import type { - RecommendationProvider, - RecommendationContext, - ScoredRecommendation, - ProviderCategory -} from '../types'; - -// --------------------------------------------------------------------------- -// Trending Provider -// --------------------------------------------------------------------------- - -interface TrendingItem { - mediaId: string; - mediaType: string; - mediaTitle: string | null; - mediaYear: number | null; - mediaGenres: string | null; - events24h: number; - events7d: number; - velocity: number; - trendingScore: number; -} - -/** - * Aggregate episode events to parent shows for trending. - * Uses parent_id/parent_title from play_sessions + show-type sessions. - */ -function computeTrendingShows(day: number, week: number): TrendingItem[] { - const raw = getRawDb(); - - // Get episode sessions aggregated by parent show - const episodeRows = raw.prepare( - `SELECT parent_id as show_id, parent_title as show_title, - MAX(media_genres) as media_genres, - COUNT(*) as total_events, - SUM(CASE WHEN started_at > ? THEN 1 ELSE 0 END) as events_24h - FROM play_sessions - WHERE started_at > ? - AND media_type = 'episode' - AND parent_id IS NOT NULL AND parent_id != '' - GROUP BY parent_id - HAVING total_events >= 2 - ORDER BY events_24h DESC - LIMIT 50` - ).all(day, week) as Array<{ - show_id: string; - show_title: string | null; - media_genres: string | null; - total_events: number; - events_24h: number; - }>; - - // Also get direct show-type sessions - const showRows = raw.prepare( - `SELECT media_id as show_id, - MAX(media_title) as show_title, - MAX(media_genres) as media_genres, - COUNT(*) as total_events, - SUM(CASE WHEN started_at > ? THEN 1 ELSE 0 END) as events_24h - FROM play_sessions - WHERE started_at > ? - AND media_type = 'show' - GROUP BY media_id - HAVING total_events >= 2 - ORDER BY events_24h DESC - LIMIT 50` - ).all(day, week) as Array<{ - show_id: string; - show_title: string | null; - media_genres: string | null; - total_events: number; - events_24h: number; - }>; - - // Merge episode and show events by show_id - const merged = new Map(); - for (const rows of [episodeRows, showRows]) { - for (const r of rows) { - const existing = merged.get(r.show_id); - if (existing) { - existing.events += r.total_events; - existing.events24h += r.events_24h; - if (!existing.title && r.show_title) existing.title = r.show_title; - if (!existing.genres && r.media_genres) existing.genres = r.media_genres; - } else { - merged.set(r.show_id, { - title: r.show_title, - genres: r.media_genres, - events: r.total_events, - events24h: r.events_24h - }); - } - } - } - - return Array.from(merged.entries()) - .map(([showId, data]) => { - const velocity = data.events > 0 ? data.events24h / data.events : 0; - return { - mediaId: showId, - mediaType: 'show', - mediaTitle: data.title, - mediaYear: null, - mediaGenres: data.genres, - events24h: data.events24h, - events7d: data.events, - velocity, - trendingScore: velocity * Math.log(data.events + 1) - }; - }) - .sort((a, b) => b.trendingScore - a.trendingScore) - .slice(0, 100); -} - -function computeTrending(mediaType?: string): TrendingItem[] { - const raw = getRawDb(); - const now = Date.now(); - const day = now - 24 * 60 * 60 * 1000; - const week = now - 7 * 24 * 60 * 60 * 1000; - - // For 'show' requests, aggregate episode watches up to their parent show. - // We only recommend shows — never individual episodes. - if (mediaType === 'show') { - return computeTrendingShows(day, week); - } - - // For specific types, filter directly. For all, exclude episodes (aggregated via show path) and videos (too noisy for trending) - const typeFilter = mediaType - ? `AND media_type = '${mediaType}'` - : `AND media_type NOT IN ('episode', 'video')`; - - const rows = raw.prepare( - `SELECT media_id, media_type, - MAX(media_title) as media_title, - MAX(media_year) as media_year, - MAX(media_genres) as media_genres, - COUNT(*) as total_events, - SUM(CASE WHEN started_at > ? THEN 1 ELSE 0 END) as events_24h - FROM play_sessions - WHERE started_at > ? ${typeFilter} - GROUP BY media_id - HAVING total_events >= 2 - ORDER BY events_24h DESC - LIMIT 100` - ).all(day, week) as Array<{ - media_id: string; - media_type: string; - media_title: string | null; - media_year: number | null; - media_genres: string | null; - total_events: number; - events_24h: number; - }>; - - return rows.map((r) => { - const velocity = r.total_events > 0 ? r.events_24h / r.total_events : 0; - return { - mediaId: r.media_id, - mediaType: r.media_type, - mediaTitle: r.media_title, - mediaYear: r.media_year, - mediaGenres: r.media_genres, - events24h: r.events_24h, - events7d: r.total_events, - velocity, - trendingScore: velocity * Math.log(r.total_events + 1) - }; - }).sort((a, b) => b.trendingScore - a.trendingScore); -} - -export const trendingProvider: RecommendationProvider = { - id: 'trending', - displayName: 'Trending Now', - category: 'trending' as ProviderCategory, - - isReady(_ctx: RecommendationContext): boolean { - const raw = getRawDb(); - const week = Date.now() - 7 * 24 * 60 * 60 * 1000; - const count = raw.prepare( - `SELECT COUNT(*) as c FROM play_sessions WHERE started_at > ?` - ).get(week) as { c: number } | undefined; - return (count?.c ?? 0) >= 5; - }, - - async getRecommendations(ctx: RecommendationContext): Promise { - const raw = getRawDb(); - - const trending = await withCache( - `trending:${ctx.mediaType ?? 'all'}`, - 5 * 60 * 1000, - async () => computeTrending(ctx.mediaType) - ); - - if (trending.length === 0) return []; - - // Resolve the Jellyfin service ID and URL for fallback items - const jellyfinService = raw.prepare( - `SELECT id, url FROM services WHERE type = 'jellyfin' LIMIT 1` - ).get() as { id: string; url: string } | undefined; - const jfServiceId = jellyfinService?.id ?? ''; - const jfBaseUrl = jellyfinService?.url ?? ''; - - const maxScore = Math.max(...trending.map((t) => t.trendingScore), 0.001); - - // Batch media_items lookup for all trending IDs (was 1 query per item). - const trendingIds = trending.map((t) => t.mediaId); - const cachedMap = new Map(); - if (trendingIds.length > 0) { - const mp = trendingIds.map(() => '?').join(','); - const rows = raw.prepare( - `SELECT * FROM media_items WHERE source_id IN (${mp})` - ).all(...trendingIds) as any[]; - for (const row of rows) { - if (!cachedMap.has(row.source_id)) cachedMap.set(row.source_id, row); - } - } - - // Batch service_id resolution for all trending IDs (was 1 query per item). - // We pick any non-empty service_id per media_id; GROUP BY + MAX does fine. - const serviceMap = new Map(); - if (trendingIds.length > 0) { - const sp = trendingIds.map(() => '?').join(','); - const rows = raw.prepare( - `SELECT media_id, MAX(service_id) as service_id FROM play_sessions - WHERE media_id IN (${sp}) AND service_id != '' - GROUP BY media_id` - ).all(...trendingIds) as Array<{ media_id: string; service_id: string }>; - for (const r of rows) serviceMap.set(r.media_id, r.service_id); - } - - const results: ScoredRecommendation[] = []; - - for (const t of trending) { - // Only skip explicitly hidden items — trending shows popular content - // even if the user has already consumed it - if (ctx.excludeIds.has(t.mediaId)) continue; - - let genres: string[] = []; - try { genres = t.mediaGenres ? JSON.parse(t.mediaGenres) : []; } catch { /* */ } - if (ctx.profile.genreBans?.some((ban) => genres.includes(ban))) continue; - - const cached = cachedMap.get(t.mediaId); - - const normalizedScore = t.trendingScore / maxScore; - - // Build image URLs based on service type - let poster: string | undefined; - let backdrop: string | undefined; - const itemServiceId = serviceMap.get(t.mediaId) ?? jfServiceId; - - if (t.mediaType === 'video') { - // YouTube/Invidious thumbnails - poster = `https://i.ytimg.com/vi/${t.mediaId}/mqdefault.jpg`; - backdrop = `https://i.ytimg.com/vi/${t.mediaId}/maxresdefault.jpg`; - } else if (jfBaseUrl) { - poster = `${jfBaseUrl}/Items/${t.mediaId}/Images/Primary?quality=90&maxWidth=400`; - backdrop = `${jfBaseUrl}/Items/${t.mediaId}/Images/Backdrop?quality=90&maxWidth=1920`; - } - - results.push({ - item: cached - ? { - id: cached.id, - sourceId: cached.source_id, - serviceId: cached.service_id, - serviceType: 'jellyfin', - type: cached.type, - title: cached.title, - description: cached.description ?? undefined, - poster: cached.poster ?? undefined, - backdrop: cached.backdrop ?? undefined, - year: cached.year ?? undefined, - rating: cached.rating ?? undefined, - genres: cached.genres ? JSON.parse(cached.genres) : genres, - duration: cached.duration ?? undefined - } - : { - id: `${t.mediaId}:${itemServiceId}`, - sourceId: t.mediaId, - serviceId: itemServiceId, - serviceType: t.mediaType === 'video' ? 'invidious' : (jfServiceId ? 'jellyfin' : 'unknown'), - type: t.mediaType as any, - title: t.mediaTitle ?? 'Unknown', - year: t.mediaYear ?? undefined, - genres, - poster, - backdrop - }, - score: normalizedScore, - confidence: Math.min(t.events7d / 20, 1), - provider: 'trending', - reason: 'Popular on Nexus this week', - reasonType: 'trending' - }); - } - - return results.slice(0, ctx.limit); - } -}; diff --git a/src/lib/server/recommendations/registry.ts b/src/lib/server/recommendations/registry.ts deleted file mode 100644 index 0717c874..00000000 --- a/src/lib/server/recommendations/registry.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { RecommendationProvider, RecommendationContext } from './types'; - -// --------------------------------------------------------------------------- -// Provider registry — simple Map-based registry for recommendation providers -// --------------------------------------------------------------------------- - -class ProviderRegistry { - private providers = new Map(); - - register(provider: RecommendationProvider) { - this.providers.set(provider.id, provider); - console.log(`[rec-registry] Registered provider: ${provider.displayName}`); - } - - get(id: string): RecommendationProvider | undefined { - return this.providers.get(id); - } - - /** Return all providers that are ready and not disabled by the user profile */ - active(ctx: RecommendationContext): RecommendationProvider[] { - const disabled = new Set(ctx.profile.disabledProviders ?? []); - return Array.from(this.providers.values()).filter( - (p) => !disabled.has(p.id) && p.isReady(ctx) - ); - } - - all(): RecommendationProvider[] { - return Array.from(this.providers.values()); - } -} - -export const recRegistry = new ProviderRegistry(); diff --git a/src/lib/server/recommendations/types.ts b/src/lib/server/recommendations/types.ts deleted file mode 100644 index 29a4a4d2..00000000 --- a/src/lib/server/recommendations/types.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { UnifiedMedia, MediaType } from '$lib/adapters/types'; -import { z } from 'zod'; - -// --------------------------------------------------------------------------- -// Recommendation types -// --------------------------------------------------------------------------- - -export type ReasonType = - | 'genre_match' - | 'similar_users' - | 'friend_shared' - | 'friend_watched' - | 'trending' - | 'time_pattern' - | 'external' - | 'similar_item' - | 'studio_match' - | 'era_match' - | 'completion_pattern'; - -export interface ScoredRecommendation { - item: UnifiedMedia; - score: number; // 0-1 relevance - confidence: number; // 0-1 provider confidence - provider: string; // provider ID - reason: string; // human-readable - reasonType: ReasonType; - basedOn?: string[]; // item titles that informed this -} - -// --------------------------------------------------------------------------- -// Canonical recommendation profile shape. -// -// `user_rec_profiles.config` is the single tuning store — the old -// `recommendation_preferences` table was dropped 2026-04-17 (migration 0010). -// The zod schema is enforced at every boundary that reads or writes a profile. -// --------------------------------------------------------------------------- - -const MEDIA_TYPE = z.enum([ - 'movie', 'show', 'episode', 'book', 'comic', 'manga', - 'game', 'music', 'album', 'track', 'podcast', 'live', 'audiobook', 'video' -]); - -export const RecProfileConfigSchema = z.object({ - weights: z.object({ - contentBased: z.number().min(0).max(1), - collaborative: z.number().min(0).max(1), - social: z.number().min(0).max(1), - trending: z.number().min(0).max(1), - external: z.number().min(0).max(1) - }), - /** Optional 0-100 per media type (legacy `mediaTypeWeights` fold-in). */ - byMediaType: z.record(MEDIA_TYPE, z.number().min(0).max(100)).optional(), - mediaTypes: z.array(MEDIA_TYPE).optional(), - genreBoosts: z.record(z.string(), z.number()).optional(), - genreBans: z.array(z.string()).optional(), - noveltyFactor: z.number().min(0).max(1).optional(), - recencyHalfLifeDays: z.number().positive().optional(), - yearRange: z.object({ - min: z.number().int().optional(), - max: z.number().int().optional() - }).optional(), - minRating: z.number().min(0).max(10).optional(), - disabledProviders: z.array(z.string()).optional(), - rowOrder: z.array(z.string()).optional() -}); - -export type RecProfileConfig = z.infer; - -export const DEFAULT_PROFILE: RecProfileConfig = { - weights: { - contentBased: 0.35, - collaborative: 0.25, - social: 0.15, - trending: 0.15, - external: 0.10 - }, - noveltyFactor: 0.3, - recencyHalfLifeDays: 30 -}; - -/** - * Parse an arbitrary JSON string (from `user_rec_profiles.config`) into a - * safe RecProfileConfig, falling back to DEFAULT_PROFILE if the blob is - * malformed or missing required fields. - */ -export function parseRecProfileConfig(raw: string | null | undefined): RecProfileConfig { - if (!raw) return DEFAULT_PROFILE; - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return DEFAULT_PROFILE; - } - const result = RecProfileConfigSchema.safeParse(parsed); - if (result.success) return result.data; - // If the blob has the legacy `mediaTypeWeights` key, try to rescue it. - if (parsed && typeof parsed === 'object' && 'mediaTypeWeights' in parsed) { - const legacy = parsed as { mediaTypeWeights?: unknown }; - const rescued: RecProfileConfig = { - ...DEFAULT_PROFILE, - byMediaType: - legacy.mediaTypeWeights && typeof legacy.mediaTypeWeights === 'object' - ? (legacy.mediaTypeWeights as Record) - : undefined - }; - return rescued; - } - return DEFAULT_PROFILE; -} - -export interface RecommendationContext { - userId: string; - mediaType?: string; - limit: number; - profile: RecProfileConfig; - excludeIds: Set; // hidden + already consumed - timeOfDay?: number; // 0-23 - dayOfWeek?: number; // 0-6 -} - -/** Provider category mapping — determines which profile weight to use */ -export type ProviderCategory = 'contentBased' | 'collaborative' | 'social' | 'trending' | 'external'; - -export interface RecommendationProvider { - readonly id: string; - readonly displayName: string; - readonly category: ProviderCategory; - readonly requiresService?: string; - - /** Check if this provider can produce results for the given context */ - isReady(ctx: RecommendationContext): boolean; - - /** Generate recommendations */ - getRecommendations(ctx: RecommendationContext): Promise; - - /** Pre-compute recommendations for caching (optional) */ - precompute?(userId: string, mediaType: string): Promise; -} diff --git a/src/lib/server/redirects.ts b/src/lib/server/redirects.ts index 305d5c4a..50d2a94d 100644 --- a/src/lib/server/redirects.ts +++ b/src/lib/server/redirects.ts @@ -74,7 +74,8 @@ export const NO_AUTH_PATHS = [ '/register', '/pending-approval', '/reset-password', - '/api/ingest/webhook' + '/api/ingest/webhook', + '/dev' // design-preview routes (mock data, no real content) — viewable without a session ] as const; export interface RedirectTarget { @@ -108,151 +109,9 @@ export function resolveRedirect( search: string = '', opts: ResolveRedirectOptions = {} ): RedirectTarget | null { - const readUserCount = opts.getUserCount ?? getUserCount; - const readSetting = opts.getSetting ?? getSetting; - - // 1. Legacy rewrite: /collections → /library/catalogs (2026-04-17). - // Disambiguates adapter-sourced catalogs from user/social collections. - if (path === '/collections' || path.startsWith('/collections/')) { - const target = '/library/catalogs' + path.slice('/collections'.length); - return { location: target + (search ?? ''), status: 301 }; - } - - // 2. First-run global: no users yet → /welcome (everything else bounces - // there). The /welcome route renders the admin-create form when - // userCount===0 && no session (see its `needsAdminCreation` branch). - // - // API paths bypass — they're data endpoints, not browser surfaces. A - // 303 to /welcome from /api/health would crash any reverse-proxy - // health check AND any polling client that doesn't follow redirects. - if (readUserCount() === 0) { - if (path.startsWith('/api')) return null; - if (!path.startsWith('/welcome')) { - return { location: '/welcome', status: 303 }; - } - return null; - } - - // 3. Per-entry-point lifecycle gates. These run BEFORE the NO_AUTH_PATHS - // short-circuit so the 4 onboarding surfaces (/register, /invite, - // /pending-approval, /welcome) are gated consistently from one place. - - // 3a. /register — gated by app_settings.registration_enabled. Already- - // logged-in users bounce home (registering a second account makes - // no sense from a signed-in session). - if (path === '/register' || path.startsWith('/register/')) { - if (user) { - return { location: '/', status: 303 }; - } - if (readSetting('registration_enabled') !== 'true') { - return { location: '/login', status: 303 }; - } - return null; - } - - // 3b. /invite — token-bearing URL. Logged-in users bounce home; anonymous - // users always get through (the page itself validates the code and - // renders "invalid/expired" if the token is bad). - if (path === '/invite' || path.startsWith('/invite/')) { - if (user) { - return { location: '/', status: 303 }; - } - return null; - } - - // 3c. /pending-approval — must be a logged-in user with status='pending'. - // Anonymous users → /login; approved users → / (they no longer belong - // here). This is the inverse of rule 5b below; keeping both lets the - // resolver be a true bidirectional state machine. - if (path === '/pending-approval' || path.startsWith('/pending-approval/')) { - if (!user) { - return { location: '/login', status: 303 }; - } - if (user.status !== 'pending') { - return { location: '/', status: 303 }; - } - return null; - } - - // 3d. /welcome — per-user first-run wizard (and, when userCount===0 was - // handled above in rule 2, the fresh-install admin-create form). Now - // that users exist, anonymous visitors bounce to /login. The - // already-completed + !?force=1 check stays in the route itself, - // because it needs to read welcome_completed_at from the DB fresh and - // the resolver already has a general "welcome for incomplete users" - // rule in step 5c. - if (path === '/welcome' || path.startsWith('/welcome/')) { - if (!user) { - return { location: '/login', status: 303 }; - } - // Account-lock states take precedence over the welcome flow — a user - // pending approval or with forcePasswordReset=true must not see - // onboarding before resolving those locks. Codex round 6 P2. - if (user.forcePasswordReset) { - return { location: '/reset-password', status: 303 }; - } - if (user.status === 'pending') { - return { location: '/pending-approval', status: 303 }; - } - return null; - } - - // 4. Other allowlisted paths short-circuit — never redirect. - if (NO_AUTH_PATHS.some((p) => path.startsWith(p))) { - return null; - } - - // 5. Logged-in users on gated paths: - if (user) { - // API routes skip onboarding/lock redirects — they get JSON 403s - // instead (see the API gate in hooks.server.ts). Returning null here - // keeps API calls from being redirected mid-request. - if (path.startsWith('/api')) { - return null; - } - - // 5a. Force password reset — lock to /reset-password. - if ( - user.forcePasswordReset && - !path.startsWith('/reset-password') && - !path.startsWith('/api/auth/logout') - ) { - return { location: '/reset-password', status: 303 }; - } - - // 5b. Pending approval — lock to /pending-approval. - if ( - user.status === 'pending' && - !path.startsWith('/pending-approval') && - !path.startsWith('/api/auth/logout') - ) { - return { location: '/pending-approval', status: 303 }; - } - - // 5c. Welcome flow (first-run per-user). /setup was folded into - // /welcome in #24, so it no longer needs its own exemption. - const welcomeCompletedAt = user.welcomeCompletedAt; - if ( - !welcomeCompletedAt && - !path.startsWith('/welcome') && - !path.startsWith('/login') && - !path.startsWith('/logout') && - !path.startsWith('/api/auth/logout') && - !path.startsWith('/reset-password') && - !path.startsWith('/_app') && - path !== '/favicon.ico' - ) { - return { location: '/welcome', status: 303 }; - } - - return null; - } - - // 6. Unauthenticated: API routes let the endpoint decide; page routes - // redirect to /login with the current URL captured in `next`. - if (path.startsWith('/api')) { - return null; - } - const next = encodeURIComponent(path + (search ?? '')); - return { location: `/login?next=${next}`, status: 303 }; + // Auth + onboarding are now owned entirely by the Authentik outpost + the + // SSO passthrough in hooks.server.ts. There are no app-owned login/welcome/ + // register/reset routes to redirect to, so this resolver no longer dispatches + // any redirect. Kept as a no-op shim so callers/imports stay stable. + return null; } diff --git a/src/lib/server/search.ts b/src/lib/server/search.ts deleted file mode 100644 index 2d99f6a1..00000000 --- a/src/lib/server/search.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Unified search surface for the three in-app search consumers: - * command palette, `/search` page, and `/requests`. Wraps `unifiedSearch` - * in `services.ts` with scope semantics that match the client helper - * (`$lib/client/unified-search`). - * - * Scopes: - * 'all' — search every searchable adapter - * 'library' — only library-type adapters (Jellyfin, Calibre, RomM, ...) - * 'discover' — only non-library adapters (Overseerr, Lidarr, ...) - * 'requestable' — discover filtered to adapters that actually implement - * `getRequests` (i.e. a request provider). Avoids the - * hardcoded `serviceType === 'overseerr'` filter. - * 'video' — handled client-side (routes to /api/video/search); server - * returns empty here so callers don't crash. - */ -import type { UnifiedMedia } from '$lib/adapters/types'; -import { unifiedSearch as legacyUnifiedSearch, getEnabledConfigs } from './services'; -import { registry } from '$lib/adapters/registry'; - -export type SearchScope = 'all' | 'library' | 'discover' | 'requestable' | 'video'; - -export interface UnifiedSearchServerOpts { - query: string; - userId?: string; - scope?: SearchScope; - type?: string; -} - -/** - * Server-side unified search. Dispatches to the legacy `unifiedSearch` in - * `services.ts` under the hood. Splits 'requestable' out into a - * capability-filtered post-pass (no hardcoded service-type strings). - */ -export async function unifiedSearch(opts: UnifiedSearchServerOpts): Promise { - const { query, userId, scope = 'all', type } = opts; - if (!query || query.trim().length < 2) return []; - - if (scope === 'video') return []; // Client handles this via /api/video/search. - - const underlying: 'library' | 'discover' | undefined = - scope === 'library' ? 'library' : scope === 'discover' || scope === 'requestable' ? 'discover' : undefined; - - let items = await legacyUnifiedSearch(query, userId, underlying); - - if (scope === 'requestable') { - // A "request provider" = any enabled adapter that implements getRequests. - // That is the same capability the movies/shows loaders check for popular - // & trending rows, so the filter stays consistent instead of relying on - // `serviceType === 'overseerr'` in three unrelated files. - const requestProviderIds = new Set( - getEnabledConfigs() - .filter((c) => !!registry.get(c.type)?.getRequests) - .map((c) => c.type) - ); - items = items.filter( - (i) => requestProviderIds.has(i.serviceType) && i.status !== 'available' - ); - } - - if (type) { - items = items.filter((i) => i.type === type); - } - - return items; -} diff --git a/src/lib/server/services.ts b/src/lib/server/services.ts deleted file mode 100644 index 22df7476..00000000 --- a/src/lib/server/services.ts +++ /dev/null @@ -1,831 +0,0 @@ -import { randomBytes } from 'crypto'; -import { and, desc, eq } from 'drizzle-orm'; -import { registry } from '../adapters/registry'; -import type { DashboardRow, ServiceConfig, ServiceHealth, UnifiedMedia, UserCredential } from '../adapters/types'; -import { getStreamyStatsRecommendations } from '../adapters/streamystats'; -import { importJellyfinUser, isOverseerrType } from '../adapters/overseerr'; -import { getDb, getRawDb, schema } from '../db'; -import { getAllUsers, getUserCredentialForService, upsertUserCredential } from './auth'; -import { withCache, withStaleCache, invalidate } from './cache'; -import { getContinueWatching as getContinueWatchingCanonical } from './continue-watching'; - -// --------------------------------------------------------------------------- -// Config helpers -// --------------------------------------------------------------------------- - -export function getServiceConfigs(): ServiceConfig[] { - const db = getDb(); - return db.select().from(schema.services).all() as ServiceConfig[]; -} - -export function getEnabledConfigs(): ServiceConfig[] { - return getServiceConfigs().filter((s) => s.enabled); -} - -/** Get enabled configs for adapters matching a media type */ -export function getConfigsForMediaType(mediaType: string): ServiceConfig[] { - return getEnabledConfigs().filter((c) => { - const adapter = registry.get(c.type); - return adapter?.mediaTypes?.includes(mediaType as any); - }); -} - -export function getServiceConfig(id: string): ServiceConfig | undefined { - const db = getDb(); - return db - .select() - .from(schema.services) - .where(eq(schema.services.id, id)) - .get() as ServiceConfig | undefined; -} - -export function upsertService(config: ServiceConfig) { - const db = getDb(); - db.insert(schema.services) - .values({ - ...config, - updatedAt: Date.now() - }) - .onConflictDoUpdate({ - target: schema.services.id, - set: { - name: config.name, - url: config.url, - apiKey: config.apiKey, - username: config.username, - password: config.password, - enabled: config.enabled, - updatedAt: Date.now() - } - }) - .run(); -} - -export function deleteService(id: string) { - const db = getDb(); - db.delete(schema.services).where(eq(schema.services.id, id)).run(); -} - -/** - * Get all user-linkable services that are currently enabled. - * Used by the UI to show which services users can link to or be provisioned on. - */ -export function getUserLinkableServices() { - return getEnabledConfigs().filter((c) => { - const adapter = registry.get(c.type); - return adapter?.userLinkable; - }).map((c) => ({ - id: c.id, - name: c.name, - type: c.type, - supportsCreate: !!registry.get(c.type)?.createUser - })); -} - -// --------------------------------------------------------------------------- -// Credential resolution -// --------------------------------------------------------------------------- - -/** - * Resolve the user credential for a service, if the service supports per-user auth. - * Returns undefined for server-only services (Radarr, Sonarr, etc.). - * - * Special case: StreamyStats authenticates via Jellyfin user tokens (not its own), - * so we look up the user's Jellyfin credential instead. - */ -export function resolveUserCred(config: ServiceConfig, userId?: string): UserCredential | undefined { - if (!userId) return undefined; - // Some adapters authenticate via another service (e.g. StreamyStats uses Jellyfin tokens). - // Handle before the userLinkable gate since these adapters may not be userLinkable themselves. - const adapter = registry.get(config.type); - if (adapter?.authVia) { - const authConfig = getEnabledConfigs().find((c) => c.type === adapter.authVia); - if (!authConfig) return undefined; - return getUserCredentialForService(userId, authConfig.id) ?? undefined; - } - if (!adapter?.userLinkable) return undefined; - return getUserCredentialForService(userId, config.id) ?? undefined; -} - -// --------------------------------------------------------------------------- -// Dashboard aggregation -// --------------------------------------------------------------------------- - -/** Media-server adapter types (things the user actually owns). */ -const LIBRARY_TYPES = new Set(registry.libraries().map((a) => a.id)); - -/** Fast dashboard rows: continue watching + new in library (local Jellyfin calls) */ -export async function getDashboardFast(userId?: string): Promise { - const configs = getEnabledConfigs(); - const libraryConfigs = configs.filter((c) => LIBRARY_TYPES.has(c.type)); - - const [continueWatching, newInLibrary] = await Promise.all([ - userId - ? withStaleCache(`cw:${userId}`, 30_000, 5 * 60_000, () => aggregateContinueWatching(configs, userId)) - : Promise.resolve([]), - withStaleCache(`new-in-library:${userId ?? 'anon'}`, 60_000, 10 * 60_000, () => aggregateRecentlyAdded(libraryConfigs, userId)) - ]); - - const rows: DashboardRow[] = []; - if (continueWatching.length > 0) { - rows.push({ id: 'continue', title: 'Continue Watching', items: continueWatching }); - } - if (newInLibrary.length > 0) { - rows.push({ - id: 'new-in-library', - title: 'New in Your Library', - subtitle: 'Recently added across your media servers', - items: newInLibrary.slice(0, 12) - }); - } - return rows; -} - -/** Slow dashboard rows: recommendation engine (content-based + StreamyStats + more) */ -export async function getDashboardPersonalized(userId?: string): Promise { - if (!userId) return []; - try { - const { getRecommendationRows } = await import('./recommendations/aggregator'); - const rows = await getRecommendationRows(userId); - if (rows.length > 0) return rows; - } catch (e) { - console.warn('[services] Recommendation engine unavailable, falling back to StreamyStats:', e instanceof Error ? e.message : e); - } - // Fallback to legacy StreamyStats-only path - return getPersonalizedRows(userId); -} - -/** Legacy combined call — still used by anything that wants all rows at once */ -export async function getDashboard(userId?: string): Promise { - const [fast, personalized] = await Promise.all([ - getDashboardFast(userId), - getDashboardPersonalized(userId) - ]); - // Interleave: continue watching first, then personalized, then new in library - const continueRow = fast.find((r) => r.id === 'continue'); - const newRow = fast.find((r) => r.id === 'new-in-library'); - const rows: DashboardRow[] = []; - if (continueRow) rows.push(continueRow); - rows.push(...personalized); - if (newRow) rows.push(newRow); - return rows; -} - -async function getPersonalizedRows(userId: string): Promise { - const ssConfigs = getEnabledConfigs().filter((c) => c.type === 'streamystats'); - if (ssConfigs.length === 0) return []; - - // StreamyStats authenticates via another adapter (e.g. Jellyfin) — resolve via authVia - const ssAdapter = registry.get('streamystats'); - const authAdapterId = ssAdapter?.authVia; - const authConfig = authAdapterId ? getEnabledConfigs().find((c) => c.type === authAdapterId) : undefined; - if (!authConfig) return []; - const userCred = getUserCredentialForService(userId, authConfig.id) ?? undefined; - if (!userCred?.accessToken) return []; - - const rows: DashboardRow[] = []; - - for (const config of ssConfigs) { - const [movies, shows] = await Promise.allSettled([ - withCache(`ss-recs-Movie:${userId}:${config.id}`, 300_000, () => - getStreamyStatsRecommendations(config, 'Movie', userCred, 24) - ), - withCache(`ss-recs-Series:${userId}:${config.id}`, 300_000, () => - getStreamyStatsRecommendations(config, 'Series', userCred, 24) - ) - ]); - - const movieItems = movies.status === 'fulfilled' ? movies.value : []; - const showItems = shows.status === 'fulfilled' ? shows.value : []; - - if (movieItems.length > 0) { - rows.push({ - id: `for-you-movies:${config.id}`, - title: 'For You — Movies', - subtitle: 'Personalized picks based on your watch history', - items: movieItems - }); - } - if (showItems.length > 0) { - rows.push({ - id: `for-you-shows:${config.id}`, - title: 'For You — Shows', - subtitle: 'Personalized picks based on your watch history', - items: showItems - }); - } - } - - return rows; -} - -async function aggregateContinueWatching(configs: ServiceConfig[], userId?: string): Promise { - // Canonical source is `play_sessions` (unified data model). The helper - // returns items ordered by `updated_at DESC` (recency). Do not re-sort. - // See src/lib/server/continue-watching.ts and the spec at - // docs/superpowers/specs/2026-04-17-player-alignment-plan.md §2. - if (!userId) return []; - return getContinueWatchingCanonical(userId, { configs }); -} - -async function aggregateRecentlyAdded(configs: ServiceConfig[], userId?: string): Promise { - const results = await Promise.allSettled( - configs.map(async (config) => { - const adapter = registry.get(config.type); - const cred = resolveUserCred(config, userId); - return adapter?.getRecentlyAdded?.(config, cred) ?? []; - }) - ); - return results.flatMap((r) => (r.status === 'fulfilled' ? r.value : [])); -} - -function getCachedLibraryItemsFromDb(opts?: { - type?: string; - limit?: number; - offset?: number; - sortBy?: string; -}): { items: UnifiedMedia[]; total: number } | null { - if (!opts?.type || !['movie', 'show'].includes(opts.type)) return null; - - const libraryConfigs = getEnabledConfigs() - .filter((c) => LIBRARY_TYPES.has(c.type)); - const libraryServiceIds = libraryConfigs - .map((c) => c.id); - - if (libraryServiceIds.length === 0) return null; - - const raw = getRawDb(); - const placeholders = libraryServiceIds.map(() => '?').join(', '); - const sortBy = opts.sortBy ?? 'title'; - const orderBy = sortBy === 'year' - ? 'year DESC, sort_title COLLATE NOCASE ASC, title COLLATE NOCASE ASC' - : sortBy === 'rating' - ? 'rating DESC, sort_title COLLATE NOCASE ASC, title COLLATE NOCASE ASC' - : sortBy === 'added' - ? 'cached_at DESC, title COLLATE NOCASE ASC' - : 'sort_title COLLATE NOCASE ASC, title COLLATE NOCASE ASC'; - - const totalRow = raw.prepare( - `SELECT COUNT(*) as count - FROM media_items - WHERE type = ? - AND service_id IN (${placeholders})` - ).get(opts.type, ...libraryServiceIds) as { count: number } | undefined; - - const rows = raw.prepare( - `SELECT source_id as sourceId, service_id as serviceId, type, title, sort_title as sortTitle, - description, poster, backdrop, year, rating, genres, studios, duration, status - FROM media_items - WHERE type = ? - AND service_id IN (${placeholders}) - ORDER BY ${orderBy} - LIMIT ? OFFSET ?` - ).all( - opts.type, - ...libraryServiceIds, - opts.limit ?? 50, - opts.offset ?? 0 - ) as Array<{ - sourceId: string; - serviceId: string; - type: string; - title: string; - sortTitle: string | null; - description: string | null; - poster: string | null; - backdrop: string | null; - year: number | null; - rating: number | null; - genres: string | null; - studios: string | null; - duration: number | null; - status: string | null; - }>; - - if ((totalRow?.count ?? 0) === 0) return null; - - return { - items: rows.map((row) => ({ - id: `${row.sourceId}:${row.serviceId}`, - sourceId: row.sourceId, - serviceId: row.serviceId, - serviceType: libraryConfigs.find((c) => c.id === row.serviceId)?.type ?? '', - type: row.type as UnifiedMedia['type'], - title: row.title, - sortTitle: row.sortTitle ?? undefined, - description: row.description ?? undefined, - poster: row.poster ?? undefined, - backdrop: row.backdrop ?? undefined, - year: row.year ?? undefined, - rating: row.rating ?? undefined, - genres: row.genres ? JSON.parse(row.genres) as string[] : [], - studios: row.studios ? JSON.parse(row.studios) as string[] : [], - duration: row.duration ?? undefined, - status: (row.status ?? 'available') as UnifiedMedia['status'] - })), - total: totalRow?.count ?? 0 - }; -} - -// --------------------------------------------------------------------------- -// Library browsing -// --------------------------------------------------------------------------- - -export async function getLibraryItems(opts?: { - type?: string; - limit?: number; - offset?: number; - sortBy?: string; - platformId?: number; -}, userId?: string): Promise<{ items: UnifiedMedia[]; total: number }> { - const dbCached = getCachedLibraryItemsFromDb(opts); - if (dbCached) return dbCached; - - // Library only shows content from actual media servers — not discovery or automation services - let configs = getEnabledConfigs().filter((c) => LIBRARY_TYPES.has(c.type)); - // When filtering by media type, only query adapters that provide that type - if (opts?.type) { - configs = configs.filter((c) => { - const adapter = registry.get(c.type); - return adapter?.mediaTypes?.includes(opts.type as any); - }); - } - const cacheKey = `library:${userId ?? 'anon'}:${opts?.type ?? 'all'}:${opts?.offset ?? 0}:${opts?.limit ?? 50}:${opts?.sortBy ?? 'default'}:${opts?.platformId ?? ''}`; - return withStaleCache(cacheKey, 300_000, 30 * 60_000, async () => { - const results = await Promise.allSettled( - configs.map(async (config) => { - const adapter = registry.get(config.type); - const cred = resolveUserCred(config, userId); - if (adapter?.getLibrary) { - return adapter.getLibrary(config, opts, cred); - } - // Fallback: getRecentlyAdded for adapters without getLibrary - const items = (await adapter?.getRecentlyAdded?.(config, cred)) ?? []; - return { items, total: items.length }; - }) - ); - const all = results.flatMap((r) => (r.status === 'fulfilled' ? r.value.items : [])); - const total = results.reduce( - (sum, r) => sum + (r.status === 'fulfilled' ? r.value.total : 0), - 0 - ); - return { items: all, total }; - }); // end withCache -} - -/** - * Browse a library surface (e.g. /movies, /shows) with real server-side - * pagination and optional text filter. - * - * `q` is applied as an in-memory filter over the paginated slice when the - * underlying adapters don't support search-within-library natively. For - * large libraries this is weaker than a native search call — for full cross- - * service search, use `unifiedSearch({ scope: 'library' })` instead. - */ -export async function browseLibrary(opts: { - type: string; - page?: number; - pageSize?: number; - sortBy?: string; - q?: string; - userId?: string; -}): Promise<{ items: UnifiedMedia[]; total: number; page: number; pageSize: number }> { - const page = Math.max(1, opts.page ?? 1); - const pageSize = Math.min(200, Math.max(1, opts.pageSize ?? 48)); - const offset = (page - 1) * pageSize; - - // If there's a search query, run library-scoped unified search and paginate - // over the full match set (search is cached upstream). - if (opts.q && opts.q.trim().length >= 2) { - const all = await legacyLibrarySearch(opts.q.trim(), opts.userId); - const filtered = all.filter((i) => i.type === opts.type); - return { - items: filtered.slice(offset, offset + pageSize), - total: filtered.length, - page, - pageSize - }; - } - - const { items, total } = await getLibraryItems( - { type: opts.type, sortBy: opts.sortBy, limit: pageSize, offset }, - opts.userId - ); - return { items, total, page, pageSize }; -} - -/** - * Internal: library-scoped search. Exists so `browseLibrary` can reuse the - * same code path as the unified-search helper without importing it (the - * unified helper re-exports `unifiedSearch` from THIS file, which would be - * a cycle). - */ -async function legacyLibrarySearch(query: string, userId?: string): Promise { - return unifiedSearch(query, userId, 'library'); -} - -export async function getAllLiveChannels(userId?: string): Promise { - return withCache(`live-channels:${userId ?? 'anon'}`, 60_000, async () => { - const configs = getEnabledConfigs(); - const results = await Promise.allSettled( - configs.map(async (config) => { - const adapter = registry.get(config.type); - const cred = resolveUserCred(config, userId); - return adapter?.getLiveChannels?.(config, cred) ?? []; - }) - ); - return results.flatMap((r) => (r.status === 'fulfilled' ? r.value : [])); - }); -} - -export async function getQueue(): Promise { - return withCache('queue', 15_000, async () => { - const configs = getEnabledConfigs(); - const results = await Promise.allSettled( - configs.map(async (config) => { - const adapter = registry.get(config.type); - return adapter?.getQueue?.(config) ?? []; - }) - ); - const items = results.flatMap((r) => (r.status === 'fulfilled' ? r.value : [])); - // Deduplicate by id — multiple services can return the same item - const seen = new Set(); - return items.filter((item) => { - if (seen.has(item.id)) return false; - seen.add(item.id); - return true; - }); - }); -} - -// --------------------------------------------------------------------------- -// Search -// --------------------------------------------------------------------------- - -export async function unifiedSearch(query: string, userId?: string, source?: 'library' | 'discover'): Promise { - const configs = getEnabledConfigs(); - - // Use searchable adapters from registry instead of hardcoded exclusion sets - const searchableIds = new Set(registry.searchable().map((a) => a.id)); - const searchPriority = Object.fromEntries( - registry.searchable().map((a) => [a.id, a.searchPriority ?? Infinity]) - ); - - // When Overseerr is present, skip radarr/sonarr to avoid duplicate results - const hasOverseerr = configs.some((c) => isOverseerrType(c.type)); - const overseerrRedundant = new Set(['radarr', 'sonarr']); - - const searchConfigs = configs.filter((c) => { - if (!searchableIds.has(c.type)) return false; - if (hasOverseerr && overseerrRedundant.has(c.type)) return false; - if (source === 'library' && !LIBRARY_TYPES.has(c.type)) return false; - if (source === 'discover' && LIBRARY_TYPES.has(c.type)) return false; - return true; - }); - - const results = await Promise.allSettled( - searchConfigs.map(async (config) => { - const adapter = registry.get(config.type); - const cred = resolveUserCred(config, userId); - const result = await adapter?.search?.(config, query, cred); - return (result?.items ?? []).map((item) => ({ ...item, _searchSource: config.type })); - }) - ); - const allItems = results.flatMap((r) => (r.status === 'fulfilled' ? r.value : [])); - - // Sort by adapter search priority (lower = higher priority) - allItems.sort((a, b) => (searchPriority[a.serviceType] ?? Infinity) - (searchPriority[b.serviceType] ?? Infinity)); - - return allItems; -} - -// --------------------------------------------------------------------------- -// Jellyfin User Migration -// --------------------------------------------------------------------------- - -export async function getJellyfinUsers() { - const configs = getEnabledConfigs().filter((c) => c.type === 'jellyfin'); - const allUsers: Array<{ serviceId: string; serviceName: string; externalId: string; username: string; isAdmin: boolean }> = []; - for (const config of configs) { - const adapter = registry.get('jellyfin'); - if (!adapter?.getUsers) continue; - const users = await adapter.getUsers(config); - for (const u of users) { - allUsers.push({ - serviceId: config.id, - serviceName: config.name, - externalId: u.externalId, - username: u.username, - isAdmin: u.isAdmin ?? false - }); - } - } - return allUsers; -} - -// --------------------------------------------------------------------------- -// Overseerr Auto-Link via Jellyfin credentials -// --------------------------------------------------------------------------- - -/** - * Fast synchronous check: does this user need auto-linking? - * Returns true if there is an Overseerr (Jellyfin mode) service - * that does not yet have a stored credential for the user. - */ -export function needsAutoLink(userId: string): boolean { - const services = getServiceConfigs(); - const jellyfinService = services.find((s) => s.type === 'jellyfin' && s.enabled); - if (!jellyfinService) return false; - const jellyfinCred = getUserCredentialForService(userId, jellyfinService.id); - if (!jellyfinCred?.externalUserId) return false; - return services.some( - (s) => - isOverseerrType(s.type) && - s.enabled && - !!s.username && - !getUserCredentialForService(userId, s.id)?.externalUserId - ); -} - -/** - * Resolve which serverUrl to send to Streamystats's `/api/recommendations`. - * Streamystats stores Jellyfin servers internally and only accepts URLs it - * already knows about (often a public/proxied URL that won't match Nexus's - * internal Jellyfin URL). We match by the Jellyfin server GUID instead. - */ -export async function resolveStreamystatsServerUrl( - streamystatsConfig: ServiceConfig, - jellyfinConfig: ServiceConfig -): Promise { - const ssBase = streamystatsConfig.url.replace(/\/+$/, ''); - const jfBase = jellyfinConfig.url.replace(/\/+$/, ''); - - let jfServerId: string | null = null; - try { - const infoRes = await fetch(`${jfBase}/System/Info/Public`, { - signal: AbortSignal.timeout(5000) - }); - if (infoRes.ok) { - const info = await infoRes.json(); - jfServerId = info?.Id ?? null; - } - } catch { - /* fall through */ - } - - try { - const serversRes = await fetch(`${ssBase}/api/servers`, { - signal: AbortSignal.timeout(5000) - }); - if (!serversRes.ok) return null; - const servers = await serversRes.json(); - if (!Array.isArray(servers) || servers.length === 0) return null; - const match = jfServerId - ? servers.find((s: { jellyfinId?: string }) => s.jellyfinId === jfServerId) - : null; - const url = (match?.url ?? servers[0]?.url ?? '').replace(/\/+$/, ''); - return url || null; - } catch { - return null; - } -} - -/** - * Silently link Overseerr (Jellyfin auth mode) using the user's Jellyfin credential. - * Safe to call without await — all errors are swallowed. - */ -export async function autoLinkJellyfinServices(userId: string): Promise { - const services = getServiceConfigs(); - const jellyfinService = services.find((s) => s.type === 'jellyfin' && s.enabled); - if (!jellyfinService) return; - const jellyfinCred = getUserCredentialForService(userId, jellyfinService.id); - if (!jellyfinCred?.externalUserId) return; - - const overseerrServices = services.filter( - (s) => isOverseerrType(s.type) && s.enabled && !!s.username - ); - - const streamystatsServices = services.filter( - (s) => s.type === 'streamystats' && s.enabled && !getUserCredentialForService(userId, s.id)?.externalUserId - ); - - await Promise.allSettled( - streamystatsServices.map(async (svc) => { - try { - const jfUrl = await resolveStreamystatsServerUrl(svc, jellyfinService); - if (!jfUrl) return; - const testUrl = new URL(`${svc.url.replace(/\/+$/, '')}/api/recommendations`); - testUrl.searchParams.set('serverUrl', jfUrl); - testUrl.searchParams.set('limit', '1'); - const res = await fetch(testUrl.toString(), { - headers: { Authorization: `MediaBrowser Token="${jellyfinCred.accessToken ?? ''}"` }, - signal: AbortSignal.timeout(8000) - }); - if (!res.ok) return; - upsertUserCredential(userId, svc.id, { - accessToken: jellyfinCred.accessToken ?? '', - externalUserId: jellyfinCred.externalUserId!, - externalUsername: jellyfinCred.externalUsername ?? '' - }); - } catch (e) { - console.warn('[Auto-link] Streamystats auto-link failed:', e instanceof Error ? e.message : e); - } - }) - ); - - await Promise.allSettled( - overseerrServices.map(async (svc) => { - const existing = getUserCredentialForService(userId, svc.id); - if (existing?.externalUserId) return; // already linked - - const adapter = registry.get(svc.type); - if (!adapter?.getUsers) return; - - try { - let users = await adapter.getUsers(svc); - let match = users.find((u) => u.jellyfinUserId === jellyfinCred.externalUserId); - - if (!match) { - const imported = await importJellyfinUser(svc, jellyfinCred.externalUserId!); - if (imported) { - users = await adapter.getUsers(svc); - match = users.find((u) => u.jellyfinUserId === jellyfinCred.externalUserId); - } - } - - if (match) { - upsertUserCredential(userId, svc.id, { - accessToken: '', - externalUserId: match.externalId, - externalUsername: match.username - }); - } - } catch (e) { - console.warn('[Auto-link] Overseerr auto-link failed:', e instanceof Error ? e.message : e); - } - }) - ); -} - -// --------------------------------------------------------------------------- -// Auto-discovery & linking -// --------------------------------------------------------------------------- - -export interface AutoLinkResult { - externalUsername: string; - externalId: string; - nexusUsername?: string; - nexusUserId?: string; - status: 'linked' | 'already-linked' | 'no-match' | 'error'; - error?: string; -} - -export async function autoDiscoverAndLink(serviceId: string): Promise { - const config = getServiceConfig(serviceId); - if (!config) throw new Error(`Service not found: ${serviceId}`); - - const adapter = registry.get(config.type); - if (!adapter) throw new Error(`No adapter registered for type: ${config.type}`); - if (!adapter.getUsers) throw new Error(`Adapter ${config.type} does not support getUsers`); - - const externalUsers = await adapter.getUsers(config); - const nexusUsers = getAllUsers(); - - // Build a map of already-linked external user IDs for this service - const linkedExternalIds = new Set(); - for (const nexusUser of nexusUsers) { - const cred = getUserCredentialForService(nexusUser.id, serviceId); - if (cred?.externalUserId) { - linkedExternalIds.add(cred.externalUserId); - } - } - - const results: AutoLinkResult[] = []; - - for (const externalUser of externalUsers) { - // Already linked — skip - if (linkedExternalIds.has(externalUser.externalId)) { - // Find which Nexus user has this credential - const linkedNexus = nexusUsers.find((u) => { - const c = getUserCredentialForService(u.id, serviceId); - return c?.externalUserId === externalUser.externalId; - }); - results.push({ - externalUsername: externalUser.username, - externalId: externalUser.externalId, - nexusUsername: linkedNexus?.username, - nexusUserId: linkedNexus?.id, - status: 'already-linked' - }); - continue; - } - - // Find a matching Nexus user by username (case-insensitive) - const match = nexusUsers.find( - (u) => u.username.toLowerCase() === externalUser.username.toLowerCase() - ); - - if (!match) { - results.push({ - externalUsername: externalUser.username, - externalId: externalUser.externalId, - status: 'no-match' - }); - continue; - } - - try { - if (adapter.resetPassword && adapter.authenticateUser) { - const tempPw = randomBytes(24).toString('base64url'); - await adapter.resetPassword(config, externalUser.externalId, tempPw); - const result = await adapter.authenticateUser(config, match.username, tempPw); - upsertUserCredential(match.id, serviceId, result); - - // Cascade dependent service links for Jellyfin-based services - await autoLinkJellyfinServices(match.id); - - results.push({ - externalUsername: externalUser.username, - externalId: externalUser.externalId, - nexusUsername: match.username, - nexusUserId: match.id, - status: 'linked' - }); - } else { - results.push({ - externalUsername: externalUser.username, - externalId: externalUser.externalId, - nexusUsername: match.username, - nexusUserId: match.id, - status: 'error', - error: 'Adapter does not support password reset or authentication' - }); - } - } catch (e) { - results.push({ - externalUsername: externalUser.username, - externalId: externalUser.externalId, - nexusUsername: match.username, - nexusUserId: match.id, - status: 'error', - error: e instanceof Error ? e.message : String(e) - }); - } - } - - return results; -} - -// --------------------------------------------------------------------------- -// Health checks -// --------------------------------------------------------------------------- - -const PING_TIMEOUT_MS = 5000; - -export async function checkAllServices(): Promise { - const result = await withCache('health', 30_000, () => checkAllServicesUncached()); - // If any service is offline, use a shorter cache so recovery is detected quickly - if (result.some((h) => !h.online)) { - invalidate('health'); - } - return result; -} - -async function checkAllServicesUncached(): Promise { - const configs = getServiceConfigs(); - return Promise.all( - configs.map((config) => { - const adapter = registry.get(config.type); - if (!adapter) { - return Promise.resolve({ - serviceId: config.id, - name: config.name, - type: config.type, - online: false, - error: 'No adapter registered' - }); - } - const ping = adapter.ping(config).catch((e): ServiceHealth => ({ - serviceId: config.id, - name: config.name, - type: config.type, - online: false, - error: String(e) - })); - const timeout = new Promise((resolve) => - setTimeout( - () => - resolve({ - serviceId: config.id, - name: config.name, - type: config.type, - online: false, - error: 'Connection timed out' - }), - PING_TIMEOUT_MS - ) - ); - return Promise.race([ping, timeout]); - }) - ); -} diff --git a/src/lib/server/session-poller.ts b/src/lib/server/session-poller.ts deleted file mode 100644 index 7ad63e5c..00000000 --- a/src/lib/server/session-poller.ts +++ /dev/null @@ -1,386 +0,0 @@ -import { getEnabledConfigs } from './services'; -import { logger } from './logger'; -import { - resolveNexusUserId, - getCredsForService, - emitMediaAction -} from './analytics'; -import { updatePresence, isGhostMode, getFriendIds } from './social'; -import { broadcastToFriends } from './ws'; -import { getRawDb } from '../db'; -import { randomBytes } from 'crypto'; -import { registry } from '../adapters/registry'; -import type { NexusSession } from '../adapters/types'; - -function updateActivityPresence(userId: string, activity: Record | null) { - updatePresence(userId, { currentActivity: activity, lastSeen: Date.now() }); - if (!isGhostMode(userId)) { - const type = activity ? 'presence:activity_started' : 'presence:activity_stopped'; - broadcastToFriends(userId, { type, data: activity ? { userId, activity } : { userId } }, () => getFriendIds(userId)); - } -} - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -interface TrackedSession { - sessionId: string; - dbId: string; - sessionKey: string; - serviceId: string; - serviceType: string; - userId: string; - mediaId: string; - mediaType: string; - mediaTitle: string; - isPaused: boolean; - startedAt: number; - lastTickAt: number; - lastSeenAt: number; - pausedSinceAt: number | null; - totalPausedMs: number; - durationMs: number; - mediaDurationMs: number | null; -} - -/** Cap play duration at media runtime + 10% tolerance (for credits, buffering) */ -function capDuration(rawMs: number, mediaDurationMs: number | null): number { - if (mediaDurationMs && rawMs > mediaDurationMs * 1.1) return mediaDurationMs; - return Math.max(0, rawMs); -} - -function genId(): string { - return randomBytes(12).toString('hex'); -} - -function insertSession( - tracker: TrackedSession, - metadata: Record, - genres?: string[], - year?: number, - parentId?: string, - parentTitle?: string, - deviceName?: string, - clientName?: string -): void { - // Previously used `INSERT ... ON CONFLICT(session_key) DO UPDATE` — that - // relied on the UNIQUE index on session_key, which migration 0013 dropped - // (see codex-audit followup for why — stable keys must be reusable across - // successive sessions). Now manually look up an open session by key and - // UPDATE it, else INSERT fresh. Codex round 3 P1. - const db = getRawDb(); - const now = Date.now(); - const existing = db - .prepare(`SELECT id FROM play_sessions WHERE session_key = ? AND ended_at IS NULL LIMIT 1`) - .get(tracker.sessionKey) as { id: string } | undefined; - - if (existing) { - // Revive an open row: reset duration/progress/completed, keep the row. - db.prepare( - `UPDATE play_sessions SET ended_at = NULL, duration_ms = 0, progress = 0, completed = 0, updated_at = ? WHERE id = ?` - ).run(now, existing.id); - // Keep tracker.dbId in sync with the row we actually wrote to. - tracker.dbId = existing.id; - return; - } - - db.prepare(` - INSERT INTO play_sessions (id, session_key, user_id, service_id, service_type, media_id, media_type, media_title, media_year, media_genres, parent_id, parent_title, started_at, ended_at, duration_ms, media_duration_ms, progress, completed, device_name, client_name, metadata, source, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, 0, 0, ?, ?, ?, 'poller', ?, ?) - `).run( - tracker.dbId, tracker.sessionKey, tracker.userId, tracker.serviceId, - tracker.serviceType, - tracker.mediaId, tracker.mediaType, tracker.mediaTitle, - year ?? null, genres ? JSON.stringify(genres) : null, - parentId ?? null, parentTitle ?? null, - tracker.startedAt, tracker.mediaDurationMs ?? null, - deviceName ?? null, clientName ?? null, - Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : null, - now, now - ); -} - -function updateSessionTick(dbId: string, durationMs: number, progress: number | null): void { - getRawDb().prepare( - `UPDATE play_sessions SET duration_ms = ?, progress = ?, updated_at = ? WHERE id = ?` - ).run(durationMs, progress, Date.now(), dbId); -} - -function closeSession(dbId: string, finalDurationMs: number, progress: number | null, completed: boolean): void { - const now = Date.now(); - getRawDb().prepare( - `UPDATE play_sessions SET ended_at = ?, duration_ms = ?, progress = ?, completed = ?, updated_at = ? WHERE id = ?` - ).run(now, finalDurationMs, progress, completed ? 1 : 0, now, dbId); -} - -// --------------------------------------------------------------------------- -// State -// --------------------------------------------------------------------------- - -const activeSessions = new Map(); - -// Per-adapter poll timers -const adapterTimers = new Map>(); - -// Track previous NexusSession state for status-change detection (used by adapters -// that report status changes like playing->finished rather than live playback) -const previousSessionState = new Map(); - -// --------------------------------------------------------------------------- -// Core — generic session processing -// --------------------------------------------------------------------------- - -function processAdapterSessions( - serviceId: string, - serviceType: string, - sessions: NexusSession[], - now: number, - seenKeys: Set -) { - for (const ns of sessions) { - const key = `${serviceId}:${ns.sessionId}`; - seenKeys.add(key); - - // Resolve Nexus user ID from the external user ID - const nexusUserId = ns.userId ? resolveNexusUserId(ns.userId, serviceId) : null; - if (!nexusUserId) continue; - - // Track status changes for non-live sessions (e.g. game status transitions) - const prevStateKey = `${serviceId}:${ns.sessionId}:state`; - const prevState = previousSessionState.get(prevStateKey); - previousSessionState.set(prevStateKey, ns.state); - - // Handle stopped sessions reported by adapter (e.g. game finished/completed) - if (ns.state === 'stopped') { - const existing = activeSessions.get(key); - if (existing) { - const progress = existing.mediaDurationMs ? Math.min(1, existing.durationMs / existing.mediaDurationMs) : null; - closeSession(existing.dbId, capDuration(existing.durationMs, existing.mediaDurationMs), progress, true); - updateActivityPresence(existing.userId, null); - activeSessions.delete(key); - } - // Emit media action for status transition - if (prevState && prevState !== 'stopped') { - emitMediaAction({ - userId: nexusUserId, - serviceId, - serviceType, - actionType: 'complete', - mediaId: ns.mediaId, - mediaType: ns.mediaType, - mediaTitle: ns.mediaTitle, - metadata: ns.metadata ?? {} - }); - } - continue; - } - - const existing = activeSessions.get(key); - const isPaused = ns.state === 'paused'; - const mediaDurationMs = ns.durationSeconds ? ns.durationSeconds * 1000 : null; - const positionMs = ns.positionSeconds ? ns.positionSeconds * 1000 : null; - - if (!existing) { - // New session — insert into play_sessions - const dbId = genId(); - const sessionKey = `${serviceId}:${ns.sessionId}`; - const tracker: TrackedSession = { - sessionId: key, - dbId, - sessionKey, - serviceId, - serviceType, - userId: nexusUserId, - mediaId: ns.mediaId, - mediaType: ns.mediaType, - mediaTitle: ns.mediaTitle, - isPaused, - startedAt: now, - lastTickAt: now, - lastSeenAt: now, - pausedSinceAt: isPaused ? now : null, - totalPausedMs: 0, - durationMs: 0, - mediaDurationMs - }; - activeSessions.set(key, tracker); - insertSession( - tracker, ns.metadata ?? {}, - ns.genres, ns.year, - ns.parentId, ns.parentTitle, - ns.device, ns.client - ); - updateActivityPresence(nexusUserId, { - mediaId: ns.mediaId, mediaType: ns.mediaType, mediaTitle: ns.mediaTitle, - serviceId, deviceName: ns.device, clientName: ns.client - }); - } else if (existing.mediaId !== ns.mediaId) { - // Media changed — close old session, start new one - const finalPaused = existing.totalPausedMs + (existing.pausedSinceAt ? now - existing.pausedSinceAt : 0); - const oldDuration = capDuration(existing.durationMs, existing.mediaDurationMs); - const oldProgress = existing.mediaDurationMs ? Math.min(1, existing.durationMs / existing.mediaDurationMs) : null; - const oldCompleted = oldProgress !== null && oldProgress >= 0.9; - closeSession(existing.dbId, oldDuration, oldProgress, oldCompleted); - - const dbId = genId(); - const sessionKey = `${serviceId}:${ns.sessionId}`; - const tracker: TrackedSession = { - sessionId: key, - dbId, - sessionKey, - serviceId, - serviceType, - userId: nexusUserId, - mediaId: ns.mediaId, - mediaType: ns.mediaType, - mediaTitle: ns.mediaTitle, - isPaused, - startedAt: now, - lastTickAt: now, - lastSeenAt: now, - pausedSinceAt: isPaused ? now : null, - totalPausedMs: 0, - durationMs: 0, - mediaDurationMs - }; - activeSessions.set(key, tracker); - insertSession( - tracker, ns.metadata ?? {}, - ns.genres, ns.year, - ns.parentId, ns.parentTitle, - ns.device, ns.client - ); - updateActivityPresence(nexusUserId, { - mediaId: ns.mediaId, mediaType: ns.mediaType, mediaTitle: ns.mediaTitle, - serviceId, deviceName: ns.device, clientName: ns.client - }); - } else { - // Same media — handle pause/resume and accumulate duration - if (isPaused && !existing.isPaused) { - existing.pausedSinceAt = now; - } else if (!isPaused && existing.isPaused) { - if (existing.pausedSinceAt) { - existing.totalPausedMs += now - existing.pausedSinceAt; - } - existing.pausedSinceAt = null; - } - existing.isPaused = isPaused; - - // Accumulate active play time (only when not paused) - if (!isPaused) { - existing.durationMs += (now - existing.lastTickAt); - // Cap at mediaDurationMs * 1.1 if available - if (existing.mediaDurationMs && existing.durationMs > existing.mediaDurationMs * 1.1) { - existing.durationMs = existing.mediaDurationMs; - } - } - - existing.lastTickAt = now; - existing.lastSeenAt = now; - - // Calculate progress from position or from accumulated duration - let progress: number | null = null; - if (positionMs && existing.mediaDurationMs) { - progress = Math.min(1, positionMs / existing.mediaDurationMs); - } else if (existing.mediaDurationMs) { - progress = Math.min(1, existing.durationMs / existing.mediaDurationMs); - } - - updateSessionTick(existing.dbId, existing.durationMs, progress); - } - } -} - -async function pollAdapterSessions(serviceType: string) { - const configs = getEnabledConfigs().filter((c) => c.type === serviceType); - const adapter = registry.get(serviceType); - if (!adapter?.pollSessions) return; - - const now = Date.now(); - const seenKeys = new Set(); - const failedServiceIds = new Set(); - - for (const config of configs) { - try { - const sessions = await adapter.pollSessions(config); - processAdapterSessions(config.id, serviceType, sessions, now, seenKeys); - } catch (e) { - failedServiceIds.add(config.id); - logger.error('Session poll error', { service: config.name, type: serviceType, err: e instanceof Error ? e.message : String(e) }); - } - } - - // Detect ended sessions — but only for services of this adapter type that we successfully polled. - for (const [key, session] of activeSessions) { - if (session.serviceType !== serviceType) continue; - if (failedServiceIds.has(session.serviceId)) continue; - if (!seenKeys.has(key)) { - const progress = session.mediaDurationMs ? Math.min(1, session.durationMs / session.mediaDurationMs) : null; - const completed = progress !== null && progress >= 0.9; - closeSession(session.dbId, capDuration(session.durationMs, session.mediaDurationMs), progress, completed); - updateActivityPresence(session.userId, null); - activeSessions.delete(key); - } - } - - // Stale session cleanup for this adapter type - const STALE_FLOOR_MS = 4 * 60 * 60 * 1000; - for (const [key, session] of activeSessions) { - if (session.serviceType !== serviceType) continue; - const staleThreshold = Math.max((session.mediaDurationMs ?? 0) * 1.5, STALE_FLOOR_MS); - if (now - session.lastSeenAt > staleThreshold) { - closeSession(session.dbId, capDuration(session.durationMs, session.mediaDurationMs), null, false); - updateActivityPresence(session.userId, null); - activeSessions.delete(key); - logger.info('Auto-closed stale session', { title: session.mediaTitle, key }); - } - } -} - -// --------------------------------------------------------------------------- -// Lifecycle -// --------------------------------------------------------------------------- - -const DEFAULT_POLL_INTERVAL_MS = 10_000; - -export function startSessionPoller() { - if (adapterTimers.size > 0) return; - - // Find all adapters that implement pollSessions - const adapters = registry.all().filter((a) => typeof a.pollSessions === 'function'); - if (adapters.length === 0) { - logger.info('No adapters with pollSessions — session poller not started'); - return; - } - - const intervals: string[] = []; - for (const adapter of adapters) { - const intervalMs = adapter.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; - intervals.push(`${adapter.id}=${intervalMs / 1000}s`); - - const timer = setInterval(() => { - pollAdapterSessions(adapter.id).catch((e) => - logger.error('Session poll error', { adapter: adapter.id, err: e instanceof Error ? e.message : String(e) }) - ); - }, intervalMs); - adapterTimers.set(adapter.id, timer); - } - - logger.info('Starting session poller', { adapters: intervals.join(', ') }); - - // Run all adapters immediately on start - for (const adapter of adapters) { - pollAdapterSessions(adapter.id).catch(() => {}); - } -} - -export function stopSessionPoller() { - if (adapterTimers.size > 0) { - for (const [id, timer] of adapterTimers) { - clearInterval(timer); - } - adapterTimers.clear(); - logger.info('Session poller stopped'); - } -} diff --git a/src/lib/server/shutdown.ts b/src/lib/server/shutdown.ts index 5d3a16e3..b50262e1 100644 --- a/src/lib/server/shutdown.ts +++ b/src/lib/server/shutdown.ts @@ -1,9 +1,4 @@ import { closeDb } from '$lib/db'; -import { stopSessionPoller } from './session-poller'; -import { stopStatsScheduler } from './stats-scheduler'; -import { stopRecScheduler } from './rec-scheduler'; -import { stopVideoNotificationPoller } from './video-notifications'; -import { stopHealthWatchdog } from './health-watchdog'; import { logger } from './logger'; let shuttingDown = false; @@ -17,14 +12,11 @@ export function registerShutdownHandler(): void { if (shuttingDown) return; shuttingDown = true; logger.info('Graceful shutdown initiated', { signal }); - - try { stopSessionPoller(); } catch { /* already stopped */ } - try { stopStatsScheduler(); } catch { /* already stopped */ } - try { stopRecScheduler(); } catch { /* already stopped */ } - try { stopVideoNotificationPoller(); } catch { /* already stopped */ } - try { stopHealthWatchdog(); } catch { /* already stopped */ } - try { closeDb(); } catch { /* already closed */ } - + try { + closeDb(); + } catch { + // already closed + } logger.info('Shutdown cleanup complete, exiting'); process.exit(0); }; diff --git a/src/lib/server/social.ts b/src/lib/server/social.ts index 9af7b717..2909dacf 100644 --- a/src/lib/server/social.ts +++ b/src/lib/server/social.ts @@ -114,7 +114,7 @@ export function getFriends(userId: string): FriendWithPresence[] { friends.push({ userId: friendId, username: user.username, - displayName: user.displayName, + displayName: user.displayName ?? user.username, avatar: user.avatar ?? null, status: isGhost ? 'offline' : (presence?.status ?? 'offline'), customStatus: isGhost ? null : (presence?.customStatus ?? null), @@ -183,10 +183,10 @@ export function getPendingRequests(userId: string): FriendRequest[] { id: row.id, fromUserId: row.userId, fromUsername: fromUser.username, - fromDisplayName: fromUser.displayName, + fromDisplayName: fromUser.displayName ?? fromUser.username, toUserId: row.friendId, toUsername: toUser.username, - toDisplayName: toUser.displayName, + toDisplayName: toUser.displayName ?? toUser.username, createdAt: row.createdAt, direction: row.userId === userId ? 'outgoing' : 'incoming' }); diff --git a/src/lib/server/stream-grant.ts b/src/lib/server/stream-grant.ts new file mode 100644 index 00000000..fd13a573 --- /dev/null +++ b/src/lib/server/stream-grant.ts @@ -0,0 +1,171 @@ +/** + * Nexus Phase-0 STREAM CORE — grant-only stream token (Node mint side). + * + * The Rust byte-proxy holds the per-backend service credential server-side; the + * token a browser ever sees carries NO credential — only a sealed, short-lived, + * session-bound **grant**. This is the CloudFront/Cloudflare signed-URL shape: + * a leaked URL has no credential to steal, a leaked URL replayed by another + * user fails the implicit-assertion (user_id) check, and rotating a backend + * credential never invalidates an outstanding token (the token never references + * the cred). + * + * Crypto: PASETO **v4.local** (XChaCha20-Poly1305 under the hood) via + * `paseto-ts` (auth70, maintained). NOT `panva/paseto` — that package is + * archived and never implemented v4.local. The Rust verifier uses `pasetors`, + * byte-compatible by the shared v4.local + PASERK `k4.local` standard. The + * lock is a cross-language golden-vector fixture in CI. + * + * - Payload claims (native-validated where applicable): + * backend, resource_ref, allowed_hops, exp (RFC3339, native exp check), gen + * - Implicit assertions (authenticated into the AEAD tag, never on the wire, + * zero size cost — PASETO's documented confused-deputy / multi-tenant + * binding mechanism): + * { user_id, hop_index, gen } + * + * The implicit assertion is serialized with a FIXED key order so the Rust side + * can reconstruct the exact same bytes (PASETO authenticates the raw assertion + * bytes — they must match exactly on both sides). + */ +import { encrypt } from 'paseto-ts/v4'; +import { hkdfSync } from 'node:crypto'; + +/** Logical backend ids the proxy can resolve a held cred for. Open set — the + * proxy fails closed on any backend it has no held cred for. */ +export type StreamBackend = string; + +export interface StreamGrant { + /** Logical backend id (NOT a URL). The proxy resolves the held service cred. */ + backend: StreamBackend; + /** Opaque item/path ref (Jellyfin ItemId+MediaSourceId, Plex ratingKey+partId, + * Invidious videoId). The proxy resolves it against the held base URL. */ + resource_ref: string; + /** Per-grant hop key the rewriter MACs emitted child URLs with (reject + * client-supplied URLs). Opaque to the token; carried as a claim. */ + allowed_hops: string; + /** Absolute expiry as epoch-seconds OR a Date. Short (minutes), slid by + * renegotiation. Converted to an RFC3339 string for the native `exp` claim. */ + exp: number | Date; + /** The Nexus user this grant was minted for. Bound via implicit assertion. */ + user_id: string; + /** Per-user generation epoch (logout/disable bump). Both a claim and an + * implicit assertion so a stale gen can be rejected at either layer. */ + gen: number; + /** Hop index this grant is scoped to (0 = entry). Bound via implicit assertion. */ + hop_index?: number; +} + +/** The implicit-assertion shape, serialized with a STABLE key order. */ +export interface ImplicitAssertion { + user_id: string; + hop_index: number; + gen: number; +} + +/** + * Canonical serialization of the implicit assertion. Key order is FIXED here + * and mirrored byte-for-byte by the Rust verifier. PASETO authenticates these + * raw bytes into the tag; any divergence (key order, spacing) → verify fails. + */ +export function serializeImplicitAssertion(a: ImplicitAssertion): string { + // Hand-built to guarantee key order + no incidental whitespace. user_id is + // JSON-string-escaped to stay safe for arbitrary ids. + return `{"user_id":${JSON.stringify(a.user_id)},"hop_index":${a.hop_index},"gen":${a.gen}}`; +} + +function toRfc3339(exp: number | Date): string { + const d = exp instanceof Date ? exp : new Date(exp * 1000); + // paseto-ts and pasetors both parse RFC3339; millisecond precision is fine. + return d.toISOString(); +} + +/** + * Mint a PASETO v4.local grant token. + * + * @param paserkLocalKey The k4.local PASERK key string (`k4.local.`), + * derived once by `deriveStreamPaserkKey` and injected + * into the Rust child env. Pass the CURRENT key. + * @param kid Optional key-id hint placed in the (unencrypted but + * authenticated) footer — drives two-key rotation on the + * Rust side (current vs previous). + */ +export function mintGrant( + grant: StreamGrant, + paserkLocalKey: string, + kid?: string, + /** TEST-ONLY: allow minting an already-expired token (golden-vector fixture). + * Never set in production — exp is always in the future for a real grant. */ + allowExpiredForTest = false +): string { + const hop_index = grant.hop_index ?? 0; + const payload = { + backend: grant.backend, + resource_ref: grant.resource_ref, + allowed_hops: grant.allowed_hops, + gen: grant.gen, + // Native exp claim — paseto-ts validates it on decrypt, pasetors via + // ClaimsValidationRules. RFC3339 string. + exp: toRfc3339(grant.exp), + }; + const assertion = serializeImplicitAssertion({ + user_id: grant.user_id, + hop_index, + gen: grant.gen, + }); + + return encrypt(paserkLocalKey, payload, { + assertion, + // We set exp ourselves; don't let the lib inject iat/exp. + addIat: false, + addExp: false, + // paseto-ts refuses to mint an already-expired payload; the golden-vector + // fixture needs exactly that, so allow it under the explicit test flag. + ...(allowExpiredForTest ? { validatePayload: false } : {}), + // Footer carries only a kid hint for rotation (authenticated, not secret). + ...(kid ? { footer: { kid } } : {}), + }); +} + +/** + * Derive a PASERK `k4.local` key string from a raw secret. + * + * The dedicated `NEXUS_STREAM_SECRET` (separate from the DB encryption key) is + * run through HKDF-SHA256 to a 32-byte sub-key, then encoded as the PASERK + * `k4.local.` form `paseto-ts` and `pasetors` both accept. Node + * derives this once and injects it into the Rust child's env; Rust never + * re-derives — it just parses the PASERK string. + * + * @param secret 32+ bytes of entropy (hex / base64 / raw utf8 all accepted). + * @param info HKDF context label — bump to rotate the derived key space. + */ +export function deriveStreamPaserkKey( + secret: string | Buffer, + info = 'nexus-stream-paseto-v4-local' +): string { + const ikm = typeof secret === 'string' ? parseSecret(secret) : secret; + if (ikm.length < 16) { + throw new Error('NEXUS_STREAM_SECRET too short (need >= 16 bytes of entropy)'); + } + // HKDF: empty salt is acceptable here (the IKM is already a high-entropy + // secret, not a low-entropy password). 32-byte output = a v4.local key. + const out = hkdfSync('sha256', ikm, Buffer.alloc(0), Buffer.from(info, 'utf8'), 32); + const keyBytes = Buffer.from(out); + return 'k4.local.' + base64url(keyBytes); +} + +function parseSecret(raw: string): Buffer { + const t = raw.trim(); + if (/^[0-9a-fA-F]+$/.test(t) && t.length % 2 === 0 && t.length >= 32) { + return Buffer.from(t, 'hex'); + } + const b64 = Buffer.from(t, 'base64'); + if (b64.length >= 16) return b64; + return Buffer.from(t, 'utf8'); +} + +function base64url(buf: Buffer): string { + return buf + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} diff --git a/src/lib/server/stream-proxy.ts b/src/lib/server/stream-proxy.ts index 86167bca..b8f5ff17 100644 --- a/src/lib/server/stream-proxy.ts +++ b/src/lib/server/stream-proxy.ts @@ -1,66 +1,158 @@ -import { spawn, type ChildProcess } from 'node:child_process'; +import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; import { existsSync } from 'node:fs'; import path from 'node:path'; +import { deriveStreamPaserkKey, mintGrant, type StreamGrant } from '$lib/server/stream-grant'; /** - * Manages the Rust stream proxy sub-process. + * Supervisor for the Rust stream byte-proxy (Phase-0 STREAM CORE). * - * Auto-starts with Nexus, auto-restarts on crash, - * auto-stops when the Node process exits. + * Responsibilities: + * - spawn / restart-with-backoff / shutdown-cleanup the loopback-only Rust child + * - inject the PASETO v4.local key (PASERK k4.local, current + previous for + * two-key rotation) into the child env — derived here, NEVER re-derived in Rust + * - inject the per-backend HELD service-cred table (base URL + auth header) into + * the child env; the proxy holds these server-side so no credential ever rides + * in a token, URL, manifest, or response + * + * The grant token the browser sees carries no secrets — only a sealed grant the + * Rust proxy verifies while holding the real cred. See `stream-grant.ts`. */ let proxyProcess: ChildProcess | null = null; let restarting = false; let restartAttempts = 0; const PORT = 3939; +const HOST = '127.0.0.1'; const MAX_RESTART_DELAY = 30_000; -export function startStreamProxy(invidiousUrl: string) { +/** A backend's held service credential — base URL + the auth header the proxy + * injects upstream. The browser never sees any of this. */ +export interface HeldCred { + /** Upstream origin/base the proxy resolves resource_ref / hop suffixes against. */ + base_url: string; + /** Header name to inject upstream (e.g. "Authorization", "X-Emby-Token"). */ + auth_header_name: string; + /** Header value (the actual service credential). */ + auth_header_value: string; +} + +export type HeldCredTable = Record; + +/** Module-level config captured at start so `mintStreamGrant` can sign without + * re-reading env / re-deriving the key on every call. */ +let currentPaserkKey: string | null = null; +const KID_CURRENT = 'k0'; + +/** Per-boot seam↔proxy shared secret. The seam attaches it as `x-nexus-proxy-auth` + * so the Rust proxy rejects anything that didn't come through the seam (defense + * in depth for the identity header — adversarial review). */ +let proxyAuthSecret: string | null = null; +/** The seam reads this to authenticate to the Rust proxy. Null until started. */ +export function getProxyAuthSecret(): string | null { + return proxyAuthSecret; +} + +export interface StartStreamProxyOptions { + /** Legacy Invidious instance URL (kept for the Invidious entry routes). */ + invidiousUrl: string; + /** Per-backend held service-cred table. Injected into the Rust child env. */ + heldCreds?: HeldCredTable; + /** Raw dedicated stream secret. HKDF-derived to the PASERK k4.local key. + * Falls back to env NEXUS_STREAM_SECRET. */ + streamSecret?: string; + /** Optional previous secret for two-key rotation (current+previous). */ + previousStreamSecret?: string; +} + +/** + * Start the Rust stream proxy. Derives + injects the PASETO key and held-cred + * table; binds loopback only. Idempotent. + */ +export function startStreamProxy(opts: StartStreamProxyOptions): void { if (proxyProcess) return; - // Find the binary const binaryPaths = [ path.resolve('stream-proxy/target/release/nexus-stream-proxy'), path.resolve('stream-proxy/target/debug/nexus-stream-proxy'), ]; - - const binaryPath = binaryPaths.find(p => existsSync(p)); + const binaryPath = binaryPaths.find((p) => existsSync(p)); if (!binaryPath) { - console.warn('[stream-proxy] Rust binary not found. Run: cd stream-proxy && cargo build --release'); + console.warn( + '[stream-proxy] Rust binary not found. Run: cd stream-proxy && cargo build --release' + ); return; } + const secret = opts.streamSecret ?? process.env.NEXUS_STREAM_SECRET; + if (!secret) { + console.warn( + '[stream-proxy] NEXUS_STREAM_SECRET not set — refusing to start (grant signing key would be undefined)' + ); + return; + } + + // Derive the current PASETO key once. Rust receives the PASERK string and + // parses it directly — it never re-derives. + const paserkCurrent = deriveStreamPaserkKey(secret); + currentPaserkKey = paserkCurrent; + // Fresh per-boot seam↔proxy secret (the seam + proxy share one process boot). + proxyAuthSecret = randomBytes(32).toString('hex'); + const paserkPrevious = opts.previousStreamSecret + ? deriveStreamPaserkKey(opts.previousStreamSecret) + : undefined; + + const heldCredsJson = JSON.stringify(opts.heldCreds ?? {}); + function launch() { - console.log(`[stream-proxy] Starting Rust proxy on port ${PORT}`); + console.log(`[stream-proxy] Starting Rust proxy on ${HOST}:${PORT}`); - proxyProcess = spawn(binaryPath!, { + // spawn(command, args, options): pass an empty args array so the options + // object (with `env`) lands in the right overload slot. Capture a non-null + // local for the listener wiring; the module-level `proxyProcess` is widened + // to ChildProcess|null (the exit handler nulls it). + // Typed as SpawnOptions so the (command, options) overload is selected — an + // untyped literal with a `stdio` array gets misread as the `args` parameter. + const spawnOpts: SpawnOptions = { env: { ...process.env, STREAM_PORT: String(PORT), - INVIDIOUS_URL: invidiousUrl, + STREAM_BIND: HOST, + INVIDIOUS_URL: opts.invidiousUrl, + // PASERK k4.local key(s). Current is mandatory; previous enables + // zero-downtime rotation (verify tries current then previous). + NEXUS_STREAM_PASETO_KEY: paserkCurrent, + ...(paserkPrevious ? { NEXUS_STREAM_PASETO_KEY_PREVIOUS: paserkPrevious } : {}), + // Seam↔proxy shared secret — the seam attaches it as x-nexus-proxy-auth. + NEXUS_PROXY_AUTH: proxyAuthSecret ?? undefined, + // Per-backend held service creds. JSON: { backend: { base_url, + // auth_header_name, auth_header_value } }. + NEXUS_STREAM_HELD_CREDS: heldCredsJson, }, stdio: ['pipe', 'pipe', 'pipe'], - }); + }; + const proc = spawn(binaryPath!, spawnOpts); + proxyProcess = proc; - proxyProcess.stdout?.on('data', (data: Buffer) => { + proc.stdout?.on('data', (data: Buffer) => { const msg = data.toString().trim(); if (msg) { console.log(msg); - if (msg.includes('Rust video proxy on port')) restartAttempts = 0; + if (msg.includes('Rust video proxy on')) restartAttempts = 0; } }); - - proxyProcess.stderr?.on('data', (data: Buffer) => { + proc.stderr?.on('data', (data: Buffer) => { const msg = data.toString().trim(); if (msg) console.error(msg); }); - - proxyProcess.on('exit', (code, signal) => { + proc.on('exit', (code, signal) => { proxyProcess = null; if (!restarting) { const delay = Math.min(2000 * 2 ** restartAttempts, MAX_RESTART_DELAY); restartAttempts++; - console.warn(`[stream-proxy] Process exited (code=${code}, signal=${signal}), restarting in ${delay / 1000}s...`); + console.warn( + `[stream-proxy] Process exited (code=${code}, signal=${signal}), restarting in ${delay / 1000}s...` + ); setTimeout(launch, delay); } }); @@ -68,9 +160,6 @@ export function startStreamProxy(invidiousUrl: string) { launch(); - // Clean shutdown — use SvelteKit's shutdown event (emitted after in-flight - // requests drain) as the primary signal, with process-level signals as a - // fallback for dev/Vite contexts where SvelteKit's event may not fire. const cleanup = () => { restarting = true; if (proxyProcess) { @@ -78,63 +167,109 @@ export function startStreamProxy(invidiousUrl: string) { proxyProcess = null; } }; - - // SvelteKit adapter-node guarantees this event is emitted when the server - // is shutting down, even if there's dangling work. Safe to register async. process.on('sveltekit:shutdown', cleanup); - - // Belt-and-suspenders for dev: Vite doesn't emit sveltekit:shutdown. process.once('SIGINT', cleanup); process.once('SIGTERM', cleanup); } -export function stopStreamProxy() { +export function stopStreamProxy(): void { restarting = true; if (proxyProcess) { proxyProcess.kill('SIGTERM'); proxyProcess = null; } + currentPaserkKey = null; +} + +/** + * Mint a grant token for a stream, using the key the supervisor derived at + * start. Returns the PASETO v4.local token (carries no credential). Callers + * embed it as `?grant=` (Invidious) or in the `/session` body (Jellyfin). + * + * Throws if the proxy hasn't been started (no key) — fail closed rather than + * mint with an undefined key. + */ +export function mintStreamGrant(grant: StreamGrant): string { + if (!currentPaserkKey) { + throw new Error('[stream-proxy] cannot mint grant: proxy not started / no PASETO key'); + } + return mintGrant(grant, currentPaserkKey, KID_CURRENT); +} + +/** Whether the proxy child is currently running. */ +export function isStreamProxyRunning(): boolean { + return proxyProcess !== null; } /** - * Create a proxy session on the Rust stream-proxy binary. Returns a signed - * stream URL the caller can 302-redirect the client to, or `null` if the - * binary isn't running (caller should fall back to the Node proxy path). + * BACK-COMPAT bridge for the existing Jellyfin/Plex playback handoff, which + * still passes an upstream URL + auth headers inline (the pre-grant adapter + * shape). It mints a grant and posts to the Rust `/session` endpoint, which + * holds the inline cred server-side and hands the browser a credential-free + * grant URL. The full adapter migration to backend-resolved held creds happens + * in the adapter-build phase; this keeps Phase-0 wiring intact. * - * Phase 1 uses this from the Jellyfin HLS route (`/api/stream/[serviceId]/[...path]`). - * Phase 2 will use it from the contract-aware negotiation endpoint. + * Returns `{ streamUrl }` (a Nexus-origin `/api/stream-proxy/...` URL) or + * `null` if the proxy isn't running (caller falls back to the Node pipe). */ export async function createStreamSession(params: { upstreamUrl: string; authHeaders?: Record; isHls?: boolean; - /** Which adapter produced this session — tells the Rust proxy whether - * to apply Plex-style workarounds (VOD-normalize manifests, enforce - * waitForSegments=1 on each hop) or pass bytes through unchanged. */ kind?: 'plex' | 'jellyfin' | 'generic'; + userId?: string; + gen?: number; }): Promise<{ streamUrl: string } | null> { - if (!proxyProcess) return null; + if (!proxyProcess || !currentPaserkKey) return null; try { - const res = await fetch(`http://127.0.0.1:${PORT}/session`, { + const userId = params.userId ?? 'legacy'; + const gen = params.gen ?? 0; + // Mint a grant bound to a synthetic "inline" backend. The proxy reads the + // inline cred from the session body but only after verifying this grant. + const grant = mintStreamGrant({ + backend: 'inline', + resource_ref: params.upstreamUrl, + allowed_hops: 'inline', + exp: Math.floor(Date.now() / 1000) + 6 * 60 * 60, + user_id: userId, + gen, + }); + const res = await fetch(`http://${HOST}:${PORT}/session`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { + 'content-type': 'application/json', + ...(proxyAuthSecret ? { 'x-nexus-proxy-auth': proxyAuthSecret } : {}) + }, body: JSON.stringify({ + grant, + // the user the grant was minted for, so the /session registration + // verifies with the same identity the browser seam will stamp. + user_id: userId, upstream_url: params.upstreamUrl, auth_headers: params.authHeaders ?? {}, is_hls: params.isHls ?? false, url_prefix: '/api/stream-proxy/', - kind: params.kind ?? 'generic' + kind: params.kind ?? 'generic', }), - signal: AbortSignal.timeout(5000) + // Loopback handoff to the Rust proxy. 5s was too tight under burst: + // a 4K load test at 40 simultaneous negotiates tripped it (the POSTs + // queue behind PlaybackInfo + the single-thread dev server, and the + // timeout counts queue time). 15s gives the tail room to complete + // without ever falling back (which fails closed anyway). + signal: AbortSignal.timeout(15000), }); if (!res.ok) { console.warn(`[stream-proxy] /session → ${res.status}`); return null; } const body = (await res.json()) as { stream_url: string }; - // Rewrite the Rust-side path (/stream/...) to the Node reverse-proxy path - // (/api/stream-proxy/...) so the browser can reach it through the Nexus origin. - const proxyPath = body.stream_url.replace(/^\/stream\//, '/api/stream-proxy/'); + // The proxy returns a root-relative URL on its own origin (e.g. + // `/stream?grant=…` for the v2 grant shape, or `/stream/…` legacy). Re-anchor + // it under the SvelteKit reverse-proxy seam so the browser hits + // `/api/stream-proxy/stream?grant=…`. Prefix-only — preserves the query + // string (where the grant lives) verbatim. + const rel = body.stream_url.startsWith('/') ? body.stream_url : `/${body.stream_url}`; + const proxyPath = `/api/stream-proxy${rel}`; return { streamUrl: proxyPath }; } catch (e) { console.warn('[stream-proxy] /session fetch error:', e); diff --git a/src/lib/server/trailers.ts b/src/lib/server/trailers.ts deleted file mode 100644 index ee000b93..00000000 --- a/src/lib/server/trailers.ts +++ /dev/null @@ -1,128 +0,0 @@ -// src/lib/server/trailers.ts -import { withCache, invalidate } from './cache'; -import { getConfigsForMediaType } from './services'; - -export interface TrailerInfo { - /** High-quality adaptive video stream (may be video-only) */ - video: string; - /** Separate audio stream for synced playback (null if video is muxed) */ - audio: string | null; -} - -/** - * Resolve trailer streams for a media item. - * 1. Check metadata.trailerUrl (from Jellyfin RemoteTrailers — typically YouTube) - * 2. If YouTube URL found + Invidious configured, resolve video + audio streams - * 3. If no Jellyfin trailer + Invidious configured, search for one - * 4. Returns video + audio URLs or null - */ -export async function resolveTrailerUrl( - mediaId: string, - serviceId: string, - title: string, - year?: number, - metadataTrailerUrl?: string | null, - userId?: string -): Promise { - const cacheKey = `trailer:${mediaId}:${serviceId}`; - - const result = await withCache(cacheKey, 24 * 60 * 60 * 1000, async () => { - const inv = getInvidiousConfig(); - if (!inv) return null; - - // Step 1: Try Jellyfin's RemoteTrailers URL (YouTube) - const youtubeId = extractYouTubeId(metadataTrailerUrl); - const videoId = youtubeId ?? await searchInvidiousTrailer(inv, title, year); - - if (!videoId) return null; - - // Resolve best audio itag for synced playback - const audioItag = await resolveBestAudioItag(inv, videoId); - - return { - video: `/api/video/stream/${videoId}`, - audio: audioItag ? `/api/video/stream/${videoId}?itag=${audioItag}` : null - }; - }); - - // Don't let a negative result poison the cache for 24h — if the user - // didn't have Invidious configured yet or a lookup transiently failed, - // we want the next call to retry. Drop null results immediately. - if (result === null) invalidate(cacheKey); - return result; -} - -function extractYouTubeId(url?: string | null): string | null { - if (!url) return null; - const match = url.match( - /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/ - ); - return match?.[1] ?? null; -} - -function getInvidiousConfig(): { serviceId: string; url: string } | null { - const videoConfigs = getConfigsForMediaType('video'); - const config = videoConfigs[0]; - if (!config) return null; - return { serviceId: config.id, url: config.url }; -} - -/** Preferred audio itags: opus > aac, higher bitrate first */ -const PREFERRED_AUDIO_ITAGS = [ - '251', // opus 160kbps - '250', // opus 70kbps - '249', // opus 50kbps - '140', // aac 128kbps - '139', // aac 48kbps -]; - -async function resolveBestAudioItag( - inv: { url: string }, - videoId: string -): Promise { - try { - const baseUrl = inv.url.replace(/\/$/, ''); - const res = await fetch( - `${baseUrl}/api/v1/videos/${encodeURIComponent(videoId)}?fields=adaptiveFormats`, - { signal: AbortSignal.timeout(8000) } - ); - if (!res.ok) return null; - const meta = await res.json(); - - const available = new Set(); - for (const f of (meta.adaptiveFormats ?? [])) { - if ((f.type ?? '').startsWith('audio/')) { - available.add(String(f.itag)); - } - } - - for (const itag of PREFERRED_AUDIO_ITAGS) { - if (available.has(itag)) return itag; - } - } catch { /* silent */ } - return null; -} - -async function searchInvidiousTrailer( - inv: { url: string }, - title: string, - year?: number -): Promise { - try { - const query = `${title} ${year ?? ''} official trailer`.trim(); - const baseUrl = inv.url.replace(/\/$/, ''); - const res = await fetch( - `${baseUrl}/api/v1/search?q=${encodeURIComponent(query)}&type=video&sort_by=relevance`, - { signal: AbortSignal.timeout(8000) } - ); - if (!res.ok) return null; - const results = await res.json(); - - const firstVideo = results?.[0]; - if (!firstVideo?.videoId) return null; - - return firstVideo.videoId; - } catch { - return null; - } -} diff --git a/src/lib/server/v2-services.ts b/src/lib/server/v2-services.ts new file mode 100644 index 00000000..0feb5aec --- /dev/null +++ b/src/lib/server/v2-services.ts @@ -0,0 +1,80 @@ +/** + * Phase-0 service-config shim for the v2 adapters. + * + * The real source of truth will be a `services` DB row per backend. Until that + * lands, the negotiate seam needs SOMETHING to hand the adapter as its single + * service `config`. For Jellyfin we read the install cred from env: + * + * NEXUS_JELLYFIN_URL e.g. http://127.0.0.1:8096 + * NEXUS_JELLYFIN_APIKEY the dashboard API key (no expiry, no device slot) + * + * A tiny shim — deliberately not a registry. `resolveServiceConfig(backend)` + * returns a ServiceConfig the adapter can use, or null if the backend isn't + * configured (the caller 404s). + */ + +import type { ServiceConfig } from '$lib/adapters/types'; +import { and, asc, eq } from 'drizzle-orm'; +import { getDb, schema } from '../db'; + +/** Oldest enabled service of a given type from the DB-backed services table. + * Deterministic tiebreak by createdAt so a duplicate-type config can't make + * playback non-deterministically pick a different credential across writes. */ +function serviceFromDb(type: string): ServiceConfig | null { + const row = getDb() + .select() + .from(schema.services) + .where(and(eq(schema.services.type, type), eq(schema.services.enabled, true))) + .orderBy(asc(schema.services.createdAt)) + .get(); + return (row as ServiceConfig | undefined) ?? null; +} + +/** Build a Jellyfin ServiceConfig from env, or null when not configured. */ +function jellyfinFromEnv(): ServiceConfig | null { + const url = process.env.NEXUS_JELLYFIN_URL; + const apiKey = process.env.NEXUS_JELLYFIN_APIKEY; + if (!url || !apiKey) return null; + return { + id: 'jellyfin', + name: 'Jellyfin', + type: 'jellyfin', + url: url.replace(/\/+$/, ''), + apiKey, + enabled: true + }; +} + +/** Build an Invidious ServiceConfig from env, or null when not configured. + * Anonymous (public content) — only the instance URL is needed. */ +function invidiousFromEnv(): ServiceConfig | null { + const url = process.env.NEXUS_INVIDIOUS_URL; + if (!url) return null; + return { + id: 'invidious', + name: 'Invidious', + type: 'invidious', + url: url.replace(/\/+$/, ''), + enabled: true + }; +} + +/** + * Resolve the single service config for a v2 backend id. Phase-0: env-backed. + * Returns null if the backend isn't configured. + */ +export function resolveServiceConfig(backend: string): ServiceConfig | null { + // The DB-backed services table is the source of truth (seeded from env on boot + // for jellyfin/invidious — see boot/seed-services.ts), so admin edits in the UI + // take effect. The env shim is only a fallback for installs that haven't seeded + // a row yet. + const fromDb = serviceFromDb(backend); + if (fromDb) return fromDb; + switch (backend) { + case 'jellyfin': + return jellyfinFromEnv(); + case 'invidious': + return invidiousFromEnv(); + } + return null; +} diff --git a/src/lib/server/video-notifications.ts b/src/lib/server/video-notifications.ts deleted file mode 100644 index 3487a162..00000000 --- a/src/lib/server/video-notifications.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { and, eq, sql } from 'drizzle-orm'; -import { getDb, schema } from '../db'; -import { createNotificationIfEnabled } from './notifications'; -import { getConfigsForMediaType } from './services'; -import { getUserCredentialForService } from './auth'; -import { getChannelVideos, normalizeVideo } from '../adapters/invidious'; - -// ── CRUD ──────────────────────────────────────────────────────── - -/** Check if a user has notifications enabled for a channel */ -export function isChannelNotifyEnabled(userId: string, channelId: string): boolean { - const db = getDb(); - const row = db - .select({ enabled: schema.videoSubNotifications.enabled }) - .from(schema.videoSubNotifications) - .where(and( - eq(schema.videoSubNotifications.userId, userId), - eq(schema.videoSubNotifications.channelId, channelId) - )) - .get(); - return row?.enabled ?? false; -} - -/** Enable notifications for a channel subscription */ -export function enableChannelNotify(userId: string, channelId: string, channelName: string): void { - const db = getDb(); - const now = Date.now(); - db.run( - sql`INSERT INTO video_sub_notifications (user_id, channel_id, channel_name, enabled, created_at, updated_at) - VALUES (${userId}, ${channelId}, ${channelName}, 1, ${now}, ${now}) - ON CONFLICT(user_id, channel_id) - DO UPDATE SET enabled = 1, channel_name = ${channelName}, updated_at = ${now}` - ); -} - -/** Disable notifications for a channel subscription */ -export function disableChannelNotify(userId: string, channelId: string): void { - const db = getDb(); - db.run( - sql`UPDATE video_sub_notifications SET enabled = 0, updated_at = ${Date.now()} - WHERE user_id = ${userId} AND channel_id = ${channelId}` - ); -} - -/** Remove notification entry entirely (used when unsubscribing) */ -export function removeChannelNotify(userId: string, channelId: string): void { - const db = getDb(); - db.delete(schema.videoSubNotifications) - .where(and( - eq(schema.videoSubNotifications.userId, userId), - eq(schema.videoSubNotifications.channelId, channelId) - )) - .run(); -} - -/** Get all channels with notifications enabled (for polling) */ -export function getAllEnabledChannelNotifications(): Array<{ - userId: string; - channelId: string; - channelName: string; - lastCheckedVideoId: string | null; -}> { - const db = getDb(); - return db - .select({ - userId: schema.videoSubNotifications.userId, - channelId: schema.videoSubNotifications.channelId, - channelName: schema.videoSubNotifications.channelName, - lastCheckedVideoId: schema.videoSubNotifications.lastCheckedVideoId - }) - .from(schema.videoSubNotifications) - .where(eq(schema.videoSubNotifications.enabled, true)) - .all(); -} - -/** Update the last checked video ID for a user+channel */ -function updateLastChecked(userId: string, channelId: string, videoId: string): void { - const db = getDb(); - db.run( - sql`UPDATE video_sub_notifications - SET last_checked_video_id = ${videoId}, updated_at = ${Date.now()} - WHERE user_id = ${userId} AND channel_id = ${channelId}` - ); -} - -// ── Poller ────────────────────────────────────────────────────── - -let pollInterval: ReturnType | null = null; - -const POLL_INTERVAL = 15 * 60 * 1000; // 15 minutes - -async function pollSubscriptionUploads() { - const configs = getConfigsForMediaType('video'); - if (configs.length === 0) return; - const config = configs[0]; - - const entries = getAllEnabledChannelNotifications(); - if (entries.length === 0) return; - - // Group by channelId to avoid fetching the same channel multiple times - const channelMap = new Map(); - for (const entry of entries) { - const list = channelMap.get(entry.channelId) ?? []; - list.push(entry); - channelMap.set(entry.channelId, list); - } - - for (const [channelId, subscribers] of channelMap) { - try { - const videosRes = await getChannelVideos(config, channelId, 'newest'); - const videos = videosRes.videos ?? []; - if (videos.length === 0) continue; - - const latestVideo = videos[0]; - const latestVideoId = latestVideo.videoId; - const normalized = normalizeVideo(config, latestVideo); - - for (const sub of subscribers) { - // First time — just seed the last checked ID, don't notify - if (!sub.lastCheckedVideoId) { - updateLastChecked(sub.userId, channelId, latestVideoId); - continue; - } - - // No new video since last check - if (sub.lastCheckedVideoId === latestVideoId) continue; - - // Find all new videos since last checked - const newVideos = []; - for (const v of videos) { - if (v.videoId === sub.lastCheckedVideoId) break; - newVideos.push(v); - } - - if (newVideos.length === 0) { - // lastCheckedVideoId might have been removed — just update - updateLastChecked(sub.userId, channelId, latestVideoId); - continue; - } - - // Create notifications for new uploads (max 3 to avoid spam) - for (const v of newVideos.slice(0, 3)) { - const norm = normalizeVideo(config, v); - createNotificationIfEnabled({ - userId: sub.userId, - type: 'video_subscription', - title: `${sub.channelName} uploaded a new video`, - message: norm.title, - href: `/media/video/${v.videoId}?service=${config.id}`, - metadata: { - channelId, - channelName: sub.channelName, - videoId: v.videoId, - thumbnail: norm.poster - } - }); - } - - if (newVideos.length > 3) { - createNotificationIfEnabled({ - userId: sub.userId, - type: 'video_subscription', - title: `${sub.channelName} uploaded ${newVideos.length} new videos`, - href: `/videos/channel/${channelId}`, - metadata: { channelId, channelName: sub.channelName, count: newVideos.length } - }); - } - - updateLastChecked(sub.userId, channelId, latestVideoId); - } - } catch { - // Silent — channel might be unreachable temporarily - } - - // Small delay between channels to avoid hammering the Invidious instance - await new Promise((r) => setTimeout(r, 500)); - } -} - -export function startVideoNotificationPoller() { - if (pollInterval) return; - // Initial poll after 2 minutes (let the server settle) - setTimeout(() => { - pollSubscriptionUploads(); - pollInterval = setInterval(pollSubscriptionUploads, POLL_INTERVAL); - }, 2 * 60 * 1000); -} - -export function stopVideoNotificationPoller() { - if (pollInterval) { - clearInterval(pollInterval); - pollInterval = null; - } -} diff --git a/src/lib/styles/paper-ink.css b/src/lib/styles/paper-ink.css new file mode 100644 index 00000000..a23066ed --- /dev/null +++ b/src/lib/styles/paper-ink.css @@ -0,0 +1,147 @@ +/* PetalNet "civic stationery" design system — paper + ink, one burnt-sienna accent. + * Canonical tokens lifted from PetalNet/homelab-docs/DESIGN.md (§2 color, §4 spacing, + * §6 radius, §7 motion). This is the source of truth for the Nexus reimplementation; + * the imported mock's inline values are NOT canonical — these are. + * + * Theme is driven by [data-theme='light'|'dark'] on a wrapping element. Tokens use + * plain CSS custom properties (no Tailwind dependency, per DESIGN.md §12). */ + +@import '@fontsource-variable/geist'; +@import '@fontsource-variable/geist-mono'; + +/* ── Color: paper (light) is the :root default ───────────────────────────── */ +:root, +[data-theme='light'] { + --bg: #ffffff; /* paper */ + --surface: #f6f5f3; /* warm stationery — filled cards */ + --elev: #fbfaf9; /* elevated/hover surface */ + --rule: #ececea; /* hairline ink rule (NOT a card border) */ + --rule-strong: #d9d7d3; /* stronger rule / outlined-tile edge */ + --text: #161412; /* warm near-black ink */ + --text-mute: #645f59; + --text-soft: #97928b; + --petal: #bc5638; /* THE accent — paper (deepened, AA on white) */ + --on-petal: #ffffff; + --petal-soft-pct: 11%; + --success: #2f8f5b; + --warning: #c77d11; + --danger: #c5374b; +} + +[data-theme='dark'] { + --bg: #0d0c0b; /* warm ink */ + --surface: #161413; + --elev: #1d1b19; + --rule: #262321; + --rule-strong: #36322f; + --text: #ece8e3; + --text-mute: #9a938b; + --text-soft: #645e57; + --petal: #e2725b; /* THE accent — ink (base, 6.4:1 on bg) */ + --on-petal: #0d0c0b; + --petal-soft-pct: 15%; +} + +/* derived accent shades — computed from --petal so they re-resolve per theme. + Literal fallback first, then the color-mix (DESIGN.md §2). */ +:root { + --petal-soft: rgba(188, 86, 56, 0.11); + --petal-soft: color-mix(in srgb, var(--petal) var(--petal-soft-pct), transparent); + --petal-hover: #a14a30; + --petal-hover: color-mix(in srgb, var(--petal) 86%, #000); + --petal-active: #8b3f29; + --petal-active: color-mix(in srgb, var(--petal) 74%, #000); + + /* ── Spacing: 8-pt system (§4) ── */ + --s1: 4px; + --s2: 8px; + --s3: 16px; + --s4: 24px; + --s5: 32px; + --s6: 48px; + --s7: 64px; + + /* ── Radius scale (§6) ── */ + --radius-xs: 2px; + --radius-sm: 8px; + --radius: 12px; + --radius-lg: 16px; + --radius-pill: 999px; + + /* ── Motion (§7) — Material 3 easing, kept short ── */ + --ease-standard: cubic-bezier(0.2, 0, 0, 1); + --ease-emph-in: cubic-bezier(0.05, 0.7, 0.1, 1); + --ease-emph-out: cubic-bezier(0.3, 0, 0.8, 0.15); + --dur-fast: 120ms; + --dur-base: 160ms; + --dur-mid: 240ms; + --dur-slow: 360ms; + --t: var(--dur-base) var(--ease-standard); + + /* ── Type (§3) ── */ + --font-sans: 'Geist Variable', system-ui, -apple-system, sans-serif; + --font-mono: 'Geist Mono Variable', ui-monospace, monospace; +} + +/* ── Base (§3 type, §7 motion, §11 a11y) ─────────────────────────────────── */ +.pi-root { + background: var(--bg); + color: var(--text); + font-family: var(--font-sans); + /* stylistic alternates + contextual ligatures so headings read as set type */ + font-feature-settings: 'ss01' 1, 'cv01' 1, 'cv11' 1; + font-variant-ligatures: common-ligatures contextual; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + /* §3 line wrapping — pretty cascades to prose; balance is applied on display text */ + text-wrap: pretty; + /* §7 — reserve the scrollbar gutter so a theme swap never shifts layout */ + scrollbar-gutter: stable; +} + +.pi-root .mono { + font-family: var(--font-mono); + font-feature-settings: 'tnum' 1; +} + +/* §7 first-paint "settle": a one-shot 2px lift, NOT scroll-driven. */ +@keyframes pi-settle { + from { + opacity: 0; + transform: translateY(2px); + } + to { + opacity: 1; + transform: none; + } +} +.pi-settle { + animation: pi-settle var(--dur-mid) var(--ease-emph-in) both; +} + +/* §7/§11 reduced motion — a real kill switch, not just slowdown. */ +@media (prefers-reduced-motion: reduce) { + .pi-root *, + .pi-root *::before, + .pi-root *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} + +/* While a theme swap commits, suspend transitions so the cut is flash-free (§7). */ +.no-theme-transition, +.no-theme-transition *, +.no-theme-transition *::before, +.no-theme-transition *::after { + transition: none !important; +} + +/* Scrollbars — thin, quiet, theme-aware (no chunky default bars). §scrollbars */ +.pi-root { scrollbar-width: thin; scrollbar-color: var(--rule-strong) transparent; } +.pi-root *::-webkit-scrollbar { width: 8px; height: 8px; } +.pi-root *::-webkit-scrollbar-track { background: transparent; } +.pi-root *::-webkit-scrollbar-thumb { background: var(--rule-strong); border-radius: 999px; } +.pi-root *::-webkit-scrollbar-thumb:hover { background: var(--text-soft); } +.pi-root *::-webkit-scrollbar-corner { background: transparent; } diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte deleted file mode 100644 index aeaf25c2..00000000 --- a/src/routes/+error.svelte +++ /dev/null @@ -1,65 +0,0 @@ - - - - Error — Nexus - - -
-
- -
- -

{$page.status}

- -

{errorConfig.title}

- -

- {errorConfig.message} -

- -
- - - Go Home - - -
-
diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index c18b082c..775c0b32 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,67 +1,8 @@ -import { registry } from '$lib/adapters/registry'; -import { getEnabledConfigs, needsAutoLink, autoLinkJellyfinServices } from '$lib/server/services'; -import { withCache } from '$lib/server/cache'; -import { getUnreadCount } from '$lib/server/notifications'; -import { getUnseenShareCount } from '$lib/server/social'; -import { getAutoplayTrailers, getAutoplayNext } from '$lib/server/user-prefs'; import type { LayoutServerLoad } from './$types'; - export const load: LayoutServerLoad = async ({ locals }) => { - // Silently wire up Overseerr (and future Jellyfin-auth services) in the background. - // The guard is a fast synchronous DB check — only fires async work when needed. - if (locals.user && needsAutoLink(locals.user.id)) { - autoLinkJellyfinServices(locals.user.id).catch(() => {}); - } - - // Notifications count is a fast sync DB query — safe to await - let unreadNotifications = 0; - let unseenShares = 0; - if (locals.user) { - unreadNotifications = getUnreadCount(locals.user.id); - // Unseen-share count is a fast sync COUNT(*) — needed by NavSidebar on every page, - // so we lift it to the root layout (was previously only loaded under /library/*). - unseenShares = getUnseenShareCount(locals.user.id); - } - - // Autoplay preferences — both routed through the canonical reader helper - // in $lib/server/user-prefs so defaults + storage coercion live in one - // place. See that file's header for the consumer list. - const autoplayTrailers = locals.user ? getAutoplayTrailers(locals.user.id) : true; - const autoplayNext = locals.user ? getAutoplayNext(locals.user.id) : false; - - // Pending request count — streamed, NEVER blocks navigation - async function fetchPendingRequests(): Promise { - if (!locals.user?.isAdmin) return 0; - const overseerrConfigs = getEnabledConfigs().filter((c) => { - const adapter = registry.get(c.type); - return !!adapter?.getRequests; - }); - const counts = await Promise.allSettled( - overseerrConfigs.map((config) => - withCache(`pending-count:${config.id}`, 30_000, async () => { - const adapter = registry.get('overseerr'); - return (await adapter?.getPendingCount?.(config)) ?? 0; - }) - ) - ); - return counts.reduce( - (sum, r) => sum + (r.status === 'fulfilled' ? r.value : 0), - 0 - ); - } - return { user: locals.user ?? null, - unreadNotifications, - unseenShares, - autoplayTrailers, - autoplayNext, - // Surfaced to the client bundle for crash-reporter + "Report issue" - // prefills. Read once at SSR so a stale tab can be distinguished - // from a fresh one in telemetry after a deploy. - buildVersion: process.env.npm_package_version ?? 'dev', - // Streamed — never blocks page navigation - pendingRequests: fetchPendingRequests(), + buildVersion: process.env.npm_package_version ?? 'dev' }; }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 96e37816..a166b936 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,177 +3,25 @@ import '@fontsource-variable/dm-sans'; import '@fontsource-variable/jetbrains-mono'; import '../app.css'; - import NavSidebar from '$lib/components/NavSidebar.svelte'; - import NotificationPanel from '$lib/components/NotificationPanel.svelte'; - import CommandPalette from '$lib/components/CommandPalette.svelte'; - import ToastContainer from '$lib/components/ToastContainer.svelte'; - import BugReportModal from '$lib/components/BugReportModal.svelte'; import { page } from '$app/stores'; - import { onMount, onDestroy } from 'svelte'; - import { onNavigate, afterNavigate, invalidateAll } from '$app/navigation'; - import { initAnalytics, trackPageView, destroyAnalytics } from '$lib/stores/analytics'; - import { installCrashReporter } from '$lib/client/crash-reporter'; - import { connectWs, disconnectWs, onMessage } from '$lib/stores/ws'; - import { probeBandwidthIfStale } from '$lib/bandwidth-probe'; - import { setNavigating } from '$lib/transition'; - import { togglePalette } from '$lib/stores/commandPalette.svelte'; - import { Bell, Menu, User, Search } from 'lucide-svelte'; - import { browser } from '$app/environment'; - import type { LayoutData } from './$types'; - import MusicPill from '$lib/components/music/MusicPill.svelte'; - import { musicPlayer } from '$lib/stores/musicStore.svelte'; - - let { children, data }: { children: import('svelte').Snippet; data: LayoutData } = $props(); - - let notifOpen = $state(false); - let notifList = $state | null; - read: boolean; createdAt: number; - }>>([]); - let unreadCount = $state(0); - $effect(() => { unreadCount = data.unreadNotifications ?? 0; }); - let pendingRequests = $state(0); - - // Resolve streamed pending count (may be Promise or already resolved number) - $effect(() => { - const val = data.pendingRequests; - if (val && typeof val === 'object' && 'then' in val) { - (val as Promise).then((n) => { pendingRequests = n; }).catch(() => {}); - } else { - pendingRequests = (val as unknown as number) ?? 0; - } - }); - let notifLoaded = $state(false); - - async function fetchNotifications() { - try { - const res = await fetch('/api/notifications?limit=30'); - const json = await res.json(); - notifList = json.notifications ?? []; - unreadCount = json.unreadCount ?? 0; - notifLoaded = true; - } catch { /* ignore */ } - } - - function handleBellClick() { - if (!notifLoaded) fetchNotifications(); - notifOpen = !notifOpen; - } - - let unsubWs: (() => void) | null = null; - let unsubRecovery: (() => void) | null = null; - - const noLayoutPaths = ['/welcome', '/login', '/register', '/pending-approval', '/reset-password', '/books/read', '/play']; - const noLayout = $derived(noLayoutPaths.some((p) => $page.url.pathname === p || $page.url.pathname.startsWith(p + '/'))); - - let sidebarCollapsed = $state(false); - let mobileOpen = $state(false); - - onMount(() => { - // Install crash reporter first so subsequent init errors get logged. - // buildVersion flows in from the root +layout.server.ts load so stale - // tabs on prior deploys show up differently in telemetry. - installCrashReporter({ buildVersion: (data as any)?.buildVersion }); - initAnalytics(); - if (data.user) { - connectWs(); - // Kick the bandwidth probe for this session (no-op if recent). - // The player uses the result to cap initial transcode bitrate so - // first play doesn't stall on slow WAN links. - probeBandwidthIfStale(); - unsubWs = onMessage('notification:new', () => { - // Re-fetch notifications when a new one arrives - fetchNotifications(); - }); - // When backend services recover from an outage, reload all page data - unsubRecovery = onMessage('services:recovered', () => { - console.log('[Nexus] Backend service(s) recovered — refreshing data'); - invalidateAll(); - }); - } - }); - onDestroy(() => { - destroyAnalytics(); - disconnectWs(); - unsubWs?.(); - unsubRecovery?.(); - }); - afterNavigate(({ to }) => { - if (to?.url) trackPageView(to.url.pathname); - mobileOpen = false; - }); - - // View transition hook - onNavigate((navigation) => { - if (!document.startViewTransition) return; - return new Promise((resolve) => { - setNavigating(true); - document.startViewTransition(async () => { - resolve(); - await navigation.complete; - setNavigating(false); - }); - }); - }); - - const activeId = $derived.by(() => { - const path = $page.url.pathname; - if (path === '/') return 'home'; - if (path.startsWith('/library/watchlist')) return 'watchlist'; - if (path.startsWith('/library/collections')) return 'collections'; - if (path.startsWith('/library/shared')) return 'shared'; - const segment = path.split('/')[1]; - const map: Record = { - movies: 'movies', - shows: 'shows', - music: 'music', - books: 'books', - games: 'games', - live: 'live', - videos: 'videos', - friends: 'friends', - requests: 'requests', - activity: 'activity', - settings: 'settings', - admin: 'admin' - }; - return map[segment] ?? 'home'; - }); - - const scopeMap: Record = { - '/movies': 'movie', - '/shows': 'show', - '/music': 'music', - '/books': 'book', - '/games': 'game', - '/videos': 'video' - }; - const searchScope = $derived(scopeMap[$page.url.pathname]); - - const isMac = $derived(browser ? navigator.platform?.includes('Mac') : true); - - let bugReportOpen = $state(false); - - function handleGlobalKeydown(e: KeyboardEvent) { - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - e.preventDefault(); - togglePalette(searchScope); - } - // Cmd/Ctrl+Alt+B — open the bug report modal from anywhere. Uses - // Alt instead of Shift because Cmd/Ctrl+Shift+B is the browser's - // "toggle bookmarks bar" shortcut and stealing it makes bookmarks - // feel broken. Cmd/Ctrl+Alt+B has no default OS or browser binding. - if ((e.metaKey || e.ctrlKey) && e.altKey && (e.key === 'b' || e.key === 'B' || e.code === 'KeyB')) { - e.preventDefault(); - bugReportOpen = true; - } - } + import Stickies from '$lib/components/Stickies.svelte'; + + let { children }: { children: import('svelte').Snippet } = $props(); + + const noLayoutPaths = [ + '/', + '/welcome', + '/login', + '/register', + '/pending-approval', + '/reset-password', + '/test-play' + ]; + const noLayout = $derived( + noLayoutPaths.some((path) => $page.url.pathname === path || $page.url.pathname.startsWith(`${path}/`)) + ); - - Nexus @@ -182,106 +30,11 @@ {#if noLayout} {@render children()} {:else} - - Skip to content - -
- - -
- -
- - - - - - -
-
- - -
- {#if data.user} -
- -
- {:else} - - - - {/if} -
-
- - -
- {@render children()} -
-
-
+
+ {@render children()} +
{/if} -{#if musicPlayer.visible && musicPlayer.currentTrack && !musicPlayer.collapsed} - -{/if} - - - - (bugReportOpen = false)} -/> - + + diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index ea78e4f6..968b64fc 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,274 +1,57 @@ -import { getDashboardFast, getEnabledConfigs } from '$lib/server/services'; -import { getHomepageCache, buildHomepageCache, applyRowOrder, cwToItem, homepageImage } from '$lib/server/homepage-cache'; -import type { HomepageRow, HomepageItem, HeroItem } from '$lib/server/homepage-cache'; -import { withCache } from '$lib/server/cache'; -import { getRecommendations } from '$lib/server/recommendations/aggregator'; -import { getRawDb } from '$lib/db'; -import { registry } from '$lib/adapters/registry'; -import { getUserCredentialForService } from '$lib/server/auth'; -import type { CalendarItem } from '$lib/adapters/types'; +import { registryV2 } from '$lib/adapters/v2'; +import type { UnifiedMedia } from '$lib/adapters/types'; +import { resolveServiceConfig } from '$lib/server/v2-services'; import type { PageServerLoad } from './$types'; -// All homepage rows — cached (from buildHomepageCache) and live (CW, calendar, -// upcoming, suggestions, new) — flow through applyRowOrder so users' rowOrder -// reaches every row. No positional hardcoding in the page component. -// See CANONICAL banner in $lib/server/homepage-cache.ts. +type HomeRow = { + id: string; + title: string; + items: UnifiedMedia[]; +}; -export const load: PageServerLoad = async ({ locals, fetch }) => { - const userId = locals.user?.id; - const hasServices = getEnabledConfigs().length > 0; +const BACKENDS = ['jellyfin', 'invidious'] as const; - // Count linkable services the user hasn't linked yet. - // Only count services the user can actually action (not derived services - // with no parent linked, not enrichment-only services). - let unlinkedServiceCount = 0; - if (userId) { - const configs = getEnabledConfigs(); - const linkedTypes = new Set(); - for (const c of configs) { - const cred = getUserCredentialForService(userId, c.id); - if (cred?.accessToken || cred?.externalUserId) linkedTypes.add(c.type); - } - unlinkedServiceCount = configs.filter((config) => { - const adapter = registry.get(config.type); - if (!adapter) return false; - if (adapter.isEnrichmentOnly) return false; - if (!adapter.userLinkable && !adapter.derivedFrom) return false; - // Skip derived services where the parent isn't linked (user can't action these) - if (adapter.derivedFrom && adapter.parentRequired) { - const parentLinked = adapter.derivedFrom.some((p) => linkedTypes.has(p)); - if (!parentLinked) return false; - } - const cred = getUserCredentialForService(userId, config.id); - return !(cred?.accessToken || cred?.externalUserId); - }).length; - } +function hasBackdrop(item: UnifiedMedia): boolean { + return Boolean(item.backdrop ?? (item as UnifiedMedia & { backdropUrl?: string }).backdropUrl); +} - // Parallel: live Continue Watching + pre-computed homepage cache + calendar + upcoming - const [dashboardRows, homepageCache, calendarRes, upcomingMoviesRes, upcomingTvRes, suggestionsRes] = await Promise.all([ - getDashboardFast(userId), - userId ? getHomepageCache(userId) : Promise.resolve(null), - fetch('/api/calendar?days=7').then((r) => (r.ok ? r.json() : [])).catch(() => []), - fetch('/api/discover?category=upcoming-movies&page=1').then((r) => (r.ok ? r.json() : null)).catch(() => null), - fetch('/api/discover?category=upcoming-tv&page=1').then((r) => (r.ok ? r.json() : null)).catch(() => null), - userId ? fetch('/api/user/suggestions').then((r) => (r.ok ? r.json() : [])).catch(() => []) : Promise.resolve([]) - ]); - const calendarItems: CalendarItem[] = Array.isArray(calendarRes) ? calendarRes : []; +export const load: PageServerLoad = async () => { + // Nexus is one unified surface over everything you host — so the home merges + // each backend's content into backend-agnostic rows. We never name the + // underlying service in the UI (no "Jellyfin Recently Added"); a viewer just + // sees "Recently Added" / "Your Library". + const recentlyAdded: UnifiedMedia[] = []; + const library: UnifiedMedia[] = []; - // Build calendar row (live-fetched, orderable like any other row). - const calendarRow: HomepageRow | null = calendarItems.length > 0 - ? { - id: 'calendar', - title: 'Coming This Week', - subtitle: 'Upcoming releases from your libraries', - type: 'calendar', - items: [], - calendarItems - } - : null; + for (const backend of BACKENDS) { + const config = resolveServiceConfig(backend); + if (!config) continue; - // Build upcoming rows from discover API - const upcomingRows: HomepageRow[] = []; - if (upcomingMoviesRes?.items?.length > 0) { - upcomingRows.push({ - id: 'upcoming-movies', - title: 'Upcoming Movies', - subtitle: 'New releases coming soon', - type: 'system', - items: upcomingMoviesRes.items.slice(0, 20).map((item: any): HomepageItem => ({ - id: item.id, - sourceId: item.sourceId, - serviceId: item.serviceId, - serviceType: item.serviceType, - title: item.title, - poster: item.poster, - backdrop: item.backdrop, - year: item.year, - mediaType: item.type ?? 'movie', - rating: item.rating, - genres: item.genres, - description: item.description - })) - }); - } - if (upcomingTvRes?.items?.length > 0) { - upcomingRows.push({ - id: 'upcoming-tv', - title: 'Upcoming Shows', - subtitle: 'New seasons and premieres', - type: 'system', - items: upcomingTvRes.items.slice(0, 20).map((item: any): HomepageItem => ({ - id: item.id, - sourceId: item.sourceId, - serviceId: item.serviceId, - serviceType: item.serviceType, - title: item.title, - poster: item.poster, - backdrop: item.backdrop, - year: item.year, - mediaType: item.type ?? 'show', - rating: item.rating, - genres: item.genres, - description: item.description - })) - }); - } + const adapter = registryV2.get(backend); + if (!adapter) continue; - // Build suggestions row from auto-suggest service - const suggestionsRow: HomepageRow | null = (suggestionsRes as any[])?.length > 0 - ? { - id: 'suggestions', - title: 'Suggested for You', - subtitle: 'Based on your recent activity', - type: 'system', - items: (suggestionsRes as any[]).slice(0, 20).map((s: any): HomepageItem => ({ - id: s.item.id, - sourceId: s.item.sourceId, - serviceId: s.item.serviceId, - serviceType: s.item.serviceType, - title: s.item.title, - poster: s.item.poster, - backdrop: s.item.backdrop, - year: s.item.year, - mediaType: s.item.type ?? 'movie', - rating: s.item.rating, - genres: s.item.genres, - description: s.item.description, - context: s.reason - })) + try { + if (adapter.getRecentlyAdded) { + recentlyAdded.push(...(await adapter.getRecentlyAdded(config))); } - : null; - - // Build Continue Watching row from live data - const cwDashRow = dashboardRows.find((r) => r.id === 'continue'); - const cwItems: HomepageItem[] = (cwDashRow?.items ?? []).map(cwToItem); - const continueRow: HomepageRow | null = cwItems.length > 0 - ? { id: 'continue', title: 'Continue Watching', type: 'system', items: cwItems } - : null; - - // New in Library from live data (used in both cache-hit and cold-start paths) - const newDashRow = dashboardRows.find((r) => r.id === 'new-in-library'); - const newRow: HomepageRow | null = newDashRow - ? { - id: 'new', - title: 'New in Your Library', - subtitle: 'Recently added across your media servers', - type: 'system', - items: newDashRow.items.map(cwToItem) - } - : null; - - if (homepageCache) { - // Cache hit — full personalized homepage - let rowOrder: string[] | undefined; - if (userId) { - const raw = getRawDb(); - const profileRow = raw.prepare( - `SELECT config FROM user_rec_profiles WHERE user_id = ? AND is_default = 1 LIMIT 1` - ).get(userId) as { config: string } | undefined; - if (profileRow?.config) { - try { - const config = JSON.parse(profileRow.config); - rowOrder = config.rowOrder; - } catch { /* use default */ } + if (adapter.getLibrary) { + const page = await adapter.getLibrary(config, { limit: 20 }); + library.push(...page.items); } + } catch (error) { + console.warn(`[home] Skipping ${backend}:`, error); } - - // Merge every row source (cache + live) through applyRowOrder. - // Continue Watching is pinned to position 0 (spec contract) after ordering. - const allRows = [...homepageCache.rows, ...upcomingRows]; - if (calendarRow) allRows.push(calendarRow); - if (suggestionsRow) allRows.push(suggestionsRow); - if (newRow) allRows.push(newRow); - - const orderedRows = applyRowOrder(allRows, rowOrder); - if (continueRow) orderedRows.unshift(continueRow); - - return { - hero: homepageCache.hero, - rows: orderedRows, - personalized: true, - hasServices, - unlinkedServiceCount, - }; - } - - // No in-memory cache — try a fast DB-backed build only. - if (userId) { - const eagerCache = buildHomepageCache(userId); - - if (eagerCache && eagerCache.rows.length > 0) { - // Store it so subsequent loads are instant - withCache(`homepage:${userId}`, 60 * 60 * 1000, async () => eagerCache); - - const allRows = [...eagerCache.rows, ...upcomingRows]; - if (calendarRow) allRows.push(calendarRow); - if (suggestionsRow) allRows.push(suggestionsRow); - if (newRow) allRows.push(newRow); - const orderedRows = applyRowOrder(allRows); - if (continueRow) orderedRows.unshift(continueRow); - - return { - hero: eagerCache.hero, - rows: orderedRows, - personalized: true, - hasServices, - unlinkedServiceCount, - }; - } - - // Cold cache: compute recommendations in the background instead of blocking the page. - void Promise.allSettled([ - getRecommendations(userId, 'movie', 30), - getRecommendations(userId, 'show', 30), - getRecommendations(userId, 'book', 20), - getRecommendations(userId, 'game', 20) - ]).then(() => { - const rebuilt = buildHomepageCache(userId); - if (!rebuilt || rebuilt.rows.length === 0) return; - void withCache(`homepage:${userId}`, 60 * 60 * 1000, async () => rebuilt); - }).catch(() => {}); } - // True cold start — no recommendation data at all. - // Still route through applyRowOrder so default ordering is the same as warm. - const coldPool: HomepageRow[] = []; - if (newRow) coldPool.push(newRow); - coldPool.push(...upcomingRows); - if (calendarRow) coldPool.push(calendarRow); - if (suggestionsRow) coldPool.push(suggestionsRow); - const coldRows = applyRowOrder(coldPool); - if (continueRow) coldRows.unshift(continueRow); + const rows: HomeRow[] = []; + if (recentlyAdded.length) rows.push({ id: 'recently-added', title: 'Recently Added', items: recentlyAdded }); + if (library.length) rows.push({ id: 'library', title: 'Your Library', items: library }); - const heroSource = cwDashRow?.items[0] ?? newDashRow?.items[0]; - const coldHero: HeroItem[] = heroSource?.backdrop - ? [{ - id: heroSource.id, - sourceId: heroSource.sourceId, - serviceId: heroSource.serviceId, - serviceType: heroSource.serviceType, - title: heroSource.title, - year: heroSource.year, - runtime: heroSource.duration - ? `${Math.floor(heroSource.duration / 3600)}h ${Math.floor((heroSource.duration % 3600) / 60)}m` - : undefined, - rating: heroSource.rating, - overview: heroSource.description, - backdrop: homepageImage(heroSource.backdrop, heroSource.serviceId, 'hero-backdrop'), - poster: homepageImage(heroSource.poster, heroSource.serviceId, 'poster'), - mediaType: heroSource.type, - genres: heroSource.genres, - reason: '', - provider: '', - streamUrl: heroSource.streamUrl - }] - : []; + const hero = rows.flatMap((row) => row.items).find(hasBackdrop) ?? null; return { - hero: coldHero, - rows: coldRows, - personalized: false, - hasServices, - unlinkedServiceCount, + rows, + hero, + hasContent: rows.some((row) => row.items.length > 0) }; }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 5b194c29..bb3b7462 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,108 +1,649 @@ - - Nexus — Home - - -
- - {#if data.hero.length > 0} - - {/if} - - - {#if data.unlinkedServiceCount > 0 && !nudgeDismissed} -
-

- {data.unlinkedServiceCount} service{data.unlinkedServiceCount > 1 ? 's' : ''} not linked to your account. - Set up your accounts to unlock more features. -

- + + +Nexus · Tonight + +
+ +
+ +
+ + Nexus
- {/if} - - - {#if data.rows.length === 0} -
-
- - - -
-

- {data.hasServices ? 'Your dashboard is still warming up' : 'No services connected'} -

-

- {data.hasServices - ? 'Your services are connected, but there is no dashboard content available yet. They may still be syncing or temporarily unavailable.' - : 'Add your media services to populate your dashboard.'} -

- {#if !data.hasServices} - Configure Services +
+ + + {#if searchOpen} +
+ {#if searchLoading && searchResults.length === 0} +
Searching…
+ {:else if searchResults.length === 0} +
No matches
+ {:else} + {#each searchResults as r, i (r.id)} + + {/each} + {/if} +
{/if}
- {:else} -
- {#each data.rows as row (row.id)} - {#if row.id === 'continue'} -
-

{row.title}

-
- {#each row.items as item (item.id)} - - {/each} + + P +
+ +
+ + + + +
+
+
+

Tonight

+ +
+

One thread through everything you're hosting, newest and nearest to done first.

+ + {#if hero && activeFilter === 'all'} + + {/if} + + {#if visibleRows.length === 0} +
+
+

Nothing here yet

- {:else if row.type === 'calendar' && row.calendarItems && row.calendarItems.length > 0} - {:else} - {@const dashRow = { - id: row.id, - title: row.title, - subtitle: row.subtitle, - items: row.items.map(item => ({ - id: item.id, - sourceId: item.sourceId, - serviceId: item.serviceId, - serviceType: item.serviceType, - type: item.mediaType as MediaType, - title: item.title, - description: item.description ?? '', - poster: item.poster, - backdrop: item.backdrop, - year: item.year, - rating: item.rating, - genres: item.genres, - streamUrl: item.streamUrl, - metadata: item.context ? { recReason: item.context } : undefined - })) - }} - + {#each visibleRows as row (row.id)} +
+

{row.title}

+
+
+ {#each row.items as item (item.id)} + + {/each} +
+ {/each} {/if} - {/each} -
- {/if} +
+ +
+ + diff --git a/src/routes/activity/+layout.server.ts b/src/routes/activity/+layout.server.ts deleted file mode 100644 index a1514cc1..00000000 --- a/src/routes/activity/+layout.server.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import { getDb, schema } from '$lib/db'; -import { eq } from 'drizzle-orm'; -import type { LayoutServerLoad } from './$types'; - -export const load: LayoutServerLoad = async ({ locals, url }) => { - if (!locals.user) throw redirect(302, '/login'); - - if (url.pathname === '/activity' || url.pathname === '/activity/') { - throw redirect(302, '/activity/insights'); - } - - const db = getDb(); - const ssService = db - .select({ id: schema.services.id }) - .from(schema.services) - .where(eq(schema.services.type, 'streamystats')) - .limit(1) - .all(); - const hasStreamyStats = ssService.length > 0; - - return { hasStreamyStats }; -}; diff --git a/src/routes/activity/+layout.svelte b/src/routes/activity/+layout.svelte deleted file mode 100644 index 414072e6..00000000 --- a/src/routes/activity/+layout.svelte +++ /dev/null @@ -1,50 +0,0 @@ - - - - Activity Hub — Nexus - - -
-
-

Activity Hub

-

Analytics, history, and personalized recommendations.

- - -
- - {@render children()} -
diff --git a/src/routes/activity/+page.server.ts b/src/routes/activity/+page.server.ts deleted file mode 100644 index 9d98931f..00000000 --- a/src/routes/activity/+page.server.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async () => { - throw redirect(302, '/activity/insights'); -}; diff --git a/src/routes/activity/+page.svelte b/src/routes/activity/+page.svelte deleted file mode 100644 index 239ac87c..00000000 --- a/src/routes/activity/+page.svelte +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/routes/activity/history/+page.server.ts b/src/routes/activity/history/+page.server.ts deleted file mode 100644 index 3f7fa040..00000000 --- a/src/routes/activity/history/+page.server.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { getRawDb, getDb, schema } from '$lib/db'; -import { getServiceConfig } from '$lib/server/services'; -import { resolveHistoryPoster } from '$lib/server/history-thumbnails'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ locals }) => { - const userId = locals.user!.id; - - const raw = getRawDb(); - const rows = raw.prepare(` - SELECT id, user_id as userId, service_id as serviceId, service_type as serviceType, - media_id as mediaId, media_type as mediaType, media_title as mediaTitle, - started_at as timestamp, duration_ms as durationMs, - media_duration_ms as mediaDurationMs, progress, completed, - device_name as deviceName, client_name as clientName - FROM play_sessions WHERE user_id = ? ORDER BY started_at DESC LIMIT 50 OFFSET 0 - `).all(userId) as any[]; - - const totalRow = raw.prepare( - `SELECT COUNT(*) as count FROM play_sessions WHERE user_id = ?` - ).get(userId) as { count: number }; - const total = totalRow.count; - - const db = getDb(); - const serviceRows = db - .select({ id: schema.services.id, name: schema.services.name, type: schema.services.type }) - .from(schema.services) - .all(); - const services = serviceRows.map((s) => ({ id: s.id, name: s.name, type: s.type })); - - // Resolve a poster URL per event using the owning service's type. - // Jellyfin/Plex images are public via the server URL; Invidious videos use - // ytimg CDN; Calibre/RomM covers are auth-gated and go through the Nexus - // image proxy. Anything else returns null (the view shows a color block). - const events = rows.map((e) => ({ - ...e, - poster: resolveHistoryPoster({ - serviceId: e.serviceId, - serviceType: e.serviceType, - mediaId: e.mediaId, - mediaType: e.mediaType, - serviceUrl: getServiceConfig(e.serviceId)?.url - }) - })); - - return { events, total, services }; -}; diff --git a/src/routes/activity/history/+page.svelte b/src/routes/activity/history/+page.svelte deleted file mode 100644 index de78f374..00000000 --- a/src/routes/activity/history/+page.svelte +++ /dev/null @@ -1,118 +0,0 @@ - - -
-
- -
- - {#if viewMode === 'feed'} - - {:else} - - {/if} - - {#if hasMore} -
- -
- {/if} -
diff --git a/src/routes/activity/insights/+page.server.ts b/src/routes/activity/insights/+page.server.ts deleted file mode 100644 index d4dc7816..00000000 --- a/src/routes/activity/insights/+page.server.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { computeStats } from '$lib/server/stats-engine'; -import { getRawDb } from '$lib/db'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ url, locals }) => { - const userId = locals.user!.id; - const now = Date.now(); - - // Parse period from URL params or default to 7 days - const fromParam = url.searchParams.get('from'); - const toParam = url.searchParams.get('to'); - - const to = toParam ? parseInt(toParam) : now; - const from = fromParam ? parseInt(fromParam) : to - 7 * 24 * 60 * 60 * 1000; - - // Compute stats for current period - const stats = computeStats(userId, from, to); - - // Compute stats for previous period (same duration, shifted back) - const duration = to - from; - const prevFrom = from - duration; - const prevTo = from; - const prevStats = computeStats(userId, prevFrom, prevTo); - - // Compute daily timeline data for the bar chart - const raw = getRawDb(); - const dailyRows = raw.prepare(` - SELECT date(started_at / 1000, 'unixepoch', 'localtime') as day, - SUM(duration_ms) as total_ms, - COUNT(*) as sessions - FROM play_sessions - WHERE user_id = ? AND started_at >= ? AND started_at <= ? - GROUP BY day - ORDER BY day ASC - `).all(userId, from, to) as { day: string; total_ms: number; sessions: number }[]; - - // Compute per-day activity for the calendar grid - const calendarRows = raw.prepare(` - SELECT date(started_at / 1000, 'unixepoch', 'localtime') as day, - SUM(duration_ms) as total_ms - FROM play_sessions - WHERE user_id = ? AND started_at >= ? AND started_at <= ? - GROUP BY day - ORDER BY day ASC - `).all(userId, from - 365 * 86400000, to) as { day: string; total_ms: number }[]; - - const dailyTimeline = dailyRows.map((r) => ({ - day: r.day, - totalMs: r.total_ms ?? 0, - sessions: r.sessions ?? 0 - })); - - const calendarData = Object.fromEntries(calendarRows.map((r) => [r.day, r.total_ms ?? 0])); - - return { - from, - to, - stats, - prevStats, - dailyTimeline, - calendarData - }; -}; diff --git a/src/routes/activity/insights/+page.svelte b/src/routes/activity/insights/+page.svelte deleted file mode 100644 index dc5c6a0c..00000000 --- a/src/routes/activity/insights/+page.svelte +++ /dev/null @@ -1,212 +0,0 @@ - - -
- -
-
- {#each presets as preset (preset.label)} - - {/each} -
- -
- applyDateRange(customFrom, customTo)} - class="rounded-lg border border-cream/[0.06] bg-raised px-2.5 py-1.5 text-xs text-cream" - /> - to - applyDateRange(customFrom, customTo)} - class="rounded-lg border border-cream/[0.06] bg-raised px-2.5 py-1.5 text-xs text-cream" - /> -
-
- - -
- {#each statCards as card (card.label)} -
-

{card.label}

-

{card.value}

-

{card.sub}

- {#if card.change} -

{card.change} vs prev period

- {/if} -
- {/each} -
- - -
-
-
-

Watch Time

- -
-
-
-
-

Media Types

- -
-
-
- - -
-
-

Top Genres

- -
-
-

Viewing Heatmap

- -
-
-

Top Items

- -
-
- - -
-
-

Activity & Streaks

- -
-
-

Playback Details

- -
- -
-
-
-
diff --git a/src/routes/activity/recommendations/+page.server.ts b/src/routes/activity/recommendations/+page.server.ts deleted file mode 100644 index 8a856c48..00000000 --- a/src/routes/activity/recommendations/+page.server.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { getRawDb } from '$lib/db'; -import { computeStats } from '$lib/server/stats-engine'; -import { parseRecProfileConfig, DEFAULT_PROFILE } from '$lib/server/recommendations/types'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ locals, parent }) => { - const userId = locals.user!.id; - const { hasStreamyStats } = await parent(); - const raw = getRawDb(); - - // Canonical RecProfileConfig blob. - const row = raw.prepare( - `SELECT config FROM user_rec_profiles WHERE user_id = ? AND is_default = 1 LIMIT 1` - ).get(userId) as { config: string } | undefined; - const profile = parseRecProfileConfig(row?.config); - - // Hidden items (the feedback-history surface — formerly the - // recommendation_feedback table). - const hiddenItems = raw.prepare( - `SELECT id, media_id, service_id, reason, created_at - FROM user_hidden_items - WHERE user_id = ? ORDER BY created_at DESC` - ).all(userId) as Array<{ - id: number; - media_id: string; - service_id: string | null; - reason: string | null; - created_at: number; - }>; - - // User's consumed genres for the tuning UI. - const allTimeStats = computeStats(userId, 0, Date.now()); - const consumedGenres = allTimeStats.topGenres.map((g) => g.genre); - - return { - hasStreamyStats, - profile, - defaultProfile: DEFAULT_PROFILE, - hiddenItems, - consumedGenres - }; -}; diff --git a/src/routes/activity/recommendations/+page.svelte b/src/routes/activity/recommendations/+page.svelte deleted file mode 100644 index 2f5cf293..00000000 --- a/src/routes/activity/recommendations/+page.svelte +++ /dev/null @@ -1,305 +0,0 @@ - - -
- -
-

Recommendations

- {#if recsLoading} -
- {#each Array(6) as _} -
- {/each} -
- {:else if recsError && recommendations.length === 0} -
- {recsError} -
- {:else if recommendations.length === 0} -
- Not enough watch history for recommendations yet. Keep watching! -
- {:else} -
- {#each recommendations as rec (rec.id)} -
- {#if rec.poster} - {rec.title} - {:else} -
- No poster -
- {/if} -

{rec.title}

-

{rec.type}

- {#if (rec as any).reason} -

{(rec as any).reason}

- {/if} - {#if (rec as any).similarity != null} -

{Math.round((rec as any).similarity * 100)}% match

- {/if} -
-
- {#each [1, 2, 3, 4, 5] as n (n)} - - {/each} -
-
- - -
-
-
- {/each} -
- {/if} -
- - -
-

Algorithm Tuning

-
-

Provider Mix

-
- {#each WEIGHT_KEYS as key (key)} -
- - {Math.round((profile.weights[key] ?? 0) * 100)}% -
- {/each} -
- - {#if data.consumedGenres.length > 0} -

Genre Preferences

-
- {#each data.consumedGenres as genre (genre)} - {@const state = genreState(genre)} - - {/each} -
- {/if} - -

Discovery Level

-
- Familiar - - Adventurous -
- - -
-
- - - {#if hiddenItems.length > 0} -
-

Hidden Items

-
- - - - - - - - - - {#each hiddenItems as h (h.id)} - - - - - - {/each} - -
MediaReasonDate
{h.media_id}{h.reason ?? '—'} - {new Date(h.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} -
-
-
- {/if} -
diff --git a/src/routes/admin/+layout.server.ts b/src/routes/admin/+layout.server.ts deleted file mode 100644 index 2988ca07..00000000 --- a/src/routes/admin/+layout.server.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import type { LayoutServerLoad } from './$types'; -import { checkAllServices } from '$lib/server/services'; - -export const load: LayoutServerLoad = async ({ locals }) => { - if (!locals.user?.isAdmin) throw redirect(302, '/'); - - const health = await checkAllServices(); - - return { health }; -}; diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte deleted file mode 100644 index 2ffb14ce..00000000 --- a/src/routes/admin/+layout.svelte +++ /dev/null @@ -1,37 +0,0 @@ - - - - Admin — Nexus - - -
- - {#snippet icon()} - - - - - - - {/snippet} - - - {@render children()} -
diff --git a/src/routes/admin/+page.server.ts b/src/routes/admin/+page.server.ts deleted file mode 100644 index 6190fc26..00000000 --- a/src/routes/admin/+page.server.ts +++ /dev/null @@ -1,250 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { getEnabledConfigs } from '$lib/server/services'; -import { registry } from '$lib/adapters/registry'; -import { withCache } from '$lib/server/cache'; -import { getConnectedUserCount } from '$lib/server/ws'; -import { getRawDb } from '$lib/db'; - -// --------------------------------------------------------------------------- -// Jellyfin session types -// --------------------------------------------------------------------------- - -export interface JellyfinSession { - Id: string; - UserId?: string; - UserName?: string; - Client?: string; - DeviceName?: string; - RemoteEndPoint?: string; - LastActivityDate?: string; - PlayState?: { - PositionTicks?: number; - IsPaused?: boolean; - PlayMethod?: 'DirectPlay' | 'DirectStream' | 'Transcode' | string; - }; - NowPlayingItem?: { - Id: string; - Name: string; - Type: string; - SeriesName?: string; - SeasonNumber?: number; - IndexNumber?: number; - ProductionYear?: number; - RunTimeTicks?: number; - ImageTags?: { Primary?: string }; - BackdropImageTags?: string[]; - /** For episodes — use parent series backdrop */ - ParentBackdropItemId?: string; - ParentBackdropImageTags?: string[]; - }; - /** Added by us — which service this came from */ - _serviceId?: string; - _serviceUrl?: string; - _serviceName?: string; - /** - * Pre-resolved backdrop/poster URLs. Used by Plex sessions (whose backdrop - * comes from `art`/`grandparentArt` not Jellyfin-shaped `BackdropImageTags`) - * so the admin UI doesn't need adapter-specific logic. (#C9) - */ - _backdropUrl?: string; - _posterUrl?: string; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -async function fetchJellyfinSessions(config: { - id: string; - name: string; - url: string; - apiKey?: string; -}): Promise { - const base = config.url.replace(/\/+$/, ''); - const res = await fetch(`${base}/Sessions`, { - headers: { - Authorization: `MediaBrowser Client="Nexus", Device="Nexus Server", DeviceId="nexus-${config.id}", Version="1.0.0", Token="${config.apiKey ?? ''}"`, - 'X-Emby-Token': config.apiKey ?? '', - Accept: 'application/json' - }, - signal: AbortSignal.timeout(8000) - }); - if (!res.ok) throw new Error(`Jellyfin sessions: ${res.status}`); - const sessions: JellyfinSession[] = await res.json(); - return sessions.map((s) => ({ - ...s, - _serviceId: config.id, - _serviceUrl: base, - _serviceName: config.name - })); -} - -/** - * Normalise Plex sessions (via adapter.pollSessions → NexusSession) into the - * JellyfinSession shape the admin UI already knows how to render. Plex reports - * time in ms and play method via TranscodeSession; we rebuild the fields the - * UI reads (PlayState.PositionTicks, PlayMethod, NowPlayingItem.*). - */ -async function fetchPlexSessions(config: { - id: string; - name: string; - url: string; -}): Promise { - const full = getEnabledConfigs().find((c) => c.id === config.id); - if (!full) return []; - const adapter = registry.get('plex'); - if (!adapter?.pollSessions) return []; - const base = config.url.replace(/\/+$/, ''); - const sessions = await adapter.pollSessions(full); - return sessions.map((s): JellyfinSession => { - const streamType = s.metadata?.streamType as string | undefined; - const isTranscoding = !!s.metadata?.isTranscoding; - const playMethod = isTranscoding - ? 'Transcode' - : streamType === 'direct-stream' - ? 'DirectStream' - : 'DirectPlay'; - const runtimeMs = s.durationSeconds ? s.durationSeconds * 1000 : undefined; - const positionMs = s.positionSeconds ? s.positionSeconds * 1000 : undefined; - // Re-map Nexus media types back to Jellyfin "Type" strings the admin UI expects. - const typeMap: Record = { - movie: 'Movie', - show: 'Series', - episode: 'Episode', - music: 'Audio', - album: 'MusicAlbum' - }; - return { - Id: s.sessionId, - UserId: s.userId, - UserName: s.username, - Client: s.client, - DeviceName: s.device, - PlayState: { - PositionTicks: positionMs ? positionMs * 10_000 : undefined, - IsPaused: s.state === 'paused', - PlayMethod: playMethod - }, - NowPlayingItem: { - Id: s.mediaId, - Name: s.mediaTitle ?? 'Unknown', - Type: typeMap[s.mediaType] ?? 'Movie', - SeriesName: s.parentTitle, - ProductionYear: s.year, - RunTimeTicks: runtimeMs ? runtimeMs * 10_000 : undefined - }, - _serviceId: config.id, - _serviceUrl: base, - _serviceName: config.name, - // Pre-resolved by the Plex adapter (#C9) — admin UI reads these - // directly instead of trying to Jellyfin-shape its way to a URL. - _backdropUrl: s.metadata?.backdropUrl as string | undefined, - _posterUrl: s.metadata?.posterUrl as string | undefined - }; - }); -} - -// --------------------------------------------------------------------------- -// Page load -// --------------------------------------------------------------------------- - -export const load: PageServerLoad = async () => { - const jellyfinConfigs = getEnabledConfigs() - .filter((c) => c.type === 'jellyfin') - .map((c) => ({ id: c.id, name: c.name, url: c.url, apiKey: c.apiKey })); - - const plexConfigs = getEnabledConfigs() - .filter((c) => c.type === 'plex') - .map((c) => ({ id: c.id, name: c.name, url: c.url })); - - const overseerrConfigs = getEnabledConfigs().filter((c) => c.type === 'overseerr'); - - const [sessionsResult, requestsResult] = await Promise.allSettled([ - // Live sessions from every configured media server (short cache — 10s). - // "Live" here means any adapter-polled session carrying a NowPlayingItem, - // including paused ones. The admin UI splits playing vs paused. - withCache('admin-sessions', 10_000, async () => { - const [jfSets, plexSets] = await Promise.all([ - Promise.allSettled(jellyfinConfigs.map(fetchJellyfinSessions)), - Promise.allSettled(plexConfigs.map(fetchPlexSessions)) - ]); - const all = [ - ...jfSets.flatMap((r) => (r.status === 'fulfilled' ? r.value : [])), - ...plexSets.flatMap((r) => (r.status === 'fulfilled' ? r.value : [])) - ]; - return all.filter((s) => s.NowPlayingItem); - }), - - // Recent requests across all Overseerr instances - withCache('admin-requests', 30_000, () => - Promise.all( - overseerrConfigs.map((config) => { - const adapter = registry.get('overseerr'); - return ( - adapter?.getRequests?.(config, { filter: 'all', take: 12 }) ?? Promise.resolve([]) - ); - }) - ).then((all) => all.flat().slice(0, 20)) - ) - ]); - - // ── Overview metrics ───────────────────────────────────────────────── - // - // Two truth sources here — keep them separate: - // • `sessions` (above) is adapter-polled; reflects what is happening RIGHT NOW. - // • `play_sessions` is a rollup table mutated in place by the session-poller - // — one row per session, `ended_at` fills in only once the session stops. - // - // Historically these two were conflated; this split is documented in - // docs/superpowers/specs/2026-04-17-surface-drift-fix-plan.md (#18). - const db = getRawDb(); - const onlineUsers = getConnectedUserCount(); - const totalUsers = (db.prepare(`SELECT COUNT(*) as count FROM users`).get() as any)?.count ?? 0; - - // Play time today — sum only the portion of each session that actually - // overlaps today's window, so cross-midnight sessions aren't double-counted - // against either day. - const todayStartDate = new Date(); - todayStartDate.setHours(0, 0, 0, 0); - const todayStart = todayStartDate.getTime(); - const tomorrowStart = todayStart + 24 * 60 * 60 * 1000; - const now = Date.now(); - const playTimeToday = (db.prepare(` - SELECT COALESCE(SUM( - MIN(COALESCE(ended_at, ?), ?) - MAX(started_at, ?) - ), 0) AS total - FROM play_sessions - WHERE COALESCE(ended_at, ?) >= ? AND started_at < ? - `).get(now, tomorrowStart, todayStart, now, todayStart, tomorrowStart) as any)?.total ?? 0; - - // Recent sessions rollup — one row per session. `ended_at` is NULL while the - // session is still in flight, so we use COALESCE(ended_at, updated_at) as the - // "last we heard from this session" timestamp. Never `started_at` alone, which - // was the historical bug (completed sessions reported their start time as the - // stop time). - const recentEvents = db.prepare(` - SELECT ps.user_id, u.display_name as userName, - CASE WHEN ps.ended_at IS NOT NULL THEN 'play_stop' ELSE 'play_start' END as eventType, - ps.media_title as mediaTitle, ps.media_type as mediaType, - COALESCE(ps.ended_at, ps.updated_at) as timestamp, - ps.duration_ms as playDurationMs - FROM play_sessions ps - LEFT JOIN users u ON u.id = ps.user_id - ORDER BY COALESCE(ps.ended_at, ps.updated_at) DESC - LIMIT 10 - `).all() as any[]; - - return { - sessions: sessionsResult.status === 'fulfilled' ? sessionsResult.value : [], - requests: requestsResult.status === 'fulfilled' ? requestsResult.value : [], - // Map of every configured media-server origin URL, keyed by serviceId. - // Kept under the legacy `jellyfinUrls` name for UI compat. - jellyfinUrls: Object.fromEntries( - [...jellyfinConfigs, ...plexConfigs].map((c) => [c.id, c.url]) - ), - onlineUsers, - totalUsers, - playTimeToday, - recentEvents - }; -}; diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte deleted file mode 100644 index 804d3ec8..00000000 --- a/src/routes/admin/+page.svelte +++ /dev/null @@ -1,9 +0,0 @@ - - -Admin — Nexus - diff --git a/src/routes/admin/analytics/+page.svelte b/src/routes/admin/analytics/+page.svelte deleted file mode 100644 index 519b7f4b..00000000 --- a/src/routes/admin/analytics/+page.svelte +++ /dev/null @@ -1,6 +0,0 @@ - - -Analytics — Admin — Nexus - diff --git a/src/routes/admin/content/+page.svelte b/src/routes/admin/content/+page.svelte deleted file mode 100644 index 532423e0..00000000 --- a/src/routes/admin/content/+page.svelte +++ /dev/null @@ -1,6 +0,0 @@ - - -Content — Admin — Nexus - diff --git a/src/routes/admin/services/+page.server.ts b/src/routes/admin/services/+page.server.ts deleted file mode 100644 index 347c0ccf..00000000 --- a/src/routes/admin/services/+page.server.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { getEnabledConfigs, getQueue, getServiceConfigs } from '$lib/server/services'; -import { registry } from '$lib/adapters/registry'; -import { withCache } from '$lib/server/cache'; -import { getProwlarrIndexers, getProwlarrStats } from '$lib/adapters/prowlarr'; - -export const load: PageServerLoad = async () => { - const services = getServiceConfigs(); - const available = registry.all().map((a) => ({ - id: a.id, - displayName: a.displayName, - defaultPort: a.defaultPort, - color: a.color, - abbreviation: a.abbreviation, - supportsGetUsers: !!a.getUsers, - userLinkable: !!a.userLinkable - })); - - const overseerrConfigs = getEnabledConfigs().filter((c) => c.type === 'overseerr'); - const prowlarrConfigs = getEnabledConfigs().filter((c) => c.type === 'prowlarr'); - const hasVideoProvider = getEnabledConfigs().some((c) => c.type === 'invidious'); - - const [requestsResult, queueResult, prowlarrResult, proxyResult] = await Promise.allSettled([ - // Recent requests across all Overseerr instances - withCache('admin-requests', 30_000, () => - Promise.all( - overseerrConfigs.map((config) => { - const adapter = registry.get('overseerr'); - return ( - adapter?.getRequests?.(config, { filter: 'all', take: 12 }) ?? Promise.resolve([]) - ); - }) - ).then((all) => all.flat().slice(0, 20)) - ), - - // Download queue from *arr services - withCache('admin-queue', 30_000, () => getQueue()), - - // Prowlarr indexer stats - withCache('admin-prowlarr', 30_000, async () => { - if (prowlarrConfigs.length === 0) return null; - const config = prowlarrConfigs[0]; - const [indexers, stats] = await Promise.all([ - getProwlarrIndexers(config), - getProwlarrStats(config) - ]); - return { indexers, stats }; - }), - - // Stream proxy stats (Rust sub-server on port 3939) - withCache('admin-proxy-stats', 10_000, async () => { - if (!hasVideoProvider) return null; - try { - const res = await fetch('http://localhost:3939/stats', { signal: AbortSignal.timeout(3000) }); - if (!res.ok) return null; - return await res.json(); - } catch { - return null; - } - }) - ]); - - return { - services, - available, - requests: requestsResult.status === 'fulfilled' ? requestsResult.value : [], - queue: queueResult.status === 'fulfilled' ? queueResult.value : [], - prowlarr: prowlarrResult.status === 'fulfilled' ? prowlarrResult.value : null, - proxyStats: proxyResult.status === 'fulfilled' ? proxyResult.value : null - }; -}; diff --git a/src/routes/admin/services/+page.svelte b/src/routes/admin/services/+page.svelte deleted file mode 100644 index 58618b82..00000000 --- a/src/routes/admin/services/+page.svelte +++ /dev/null @@ -1,9 +0,0 @@ - - -Services — Admin — Nexus - diff --git a/src/routes/admin/system/+page.svelte b/src/routes/admin/system/+page.svelte deleted file mode 100644 index 4d4151c1..00000000 --- a/src/routes/admin/system/+page.svelte +++ /dev/null @@ -1,6 +0,0 @@ - - -System — Admin — Nexus - diff --git a/src/routes/admin/users/+page.svelte b/src/routes/admin/users/+page.svelte deleted file mode 100644 index 818c3f7d..00000000 --- a/src/routes/admin/users/+page.svelte +++ /dev/null @@ -1,6 +0,0 @@ - - -Users — Admin — Nexus - diff --git a/src/routes/api/activity/+server.ts b/src/routes/api/activity/+server.ts deleted file mode 100644 index df644433..00000000 --- a/src/routes/api/activity/+server.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { getFriendActivity } from '$lib/server/social'; -import type { RequestHandler } from './$types'; - -// GET /api/activity?limit=50&offset=0&mediaId=X&serviceId=Y -export const GET: RequestHandler = async ({ url, locals }) => { - if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 }); - - const limit = parseInt(url.searchParams.get('limit') ?? '50'); - const offset = parseInt(url.searchParams.get('offset') ?? '0'); - const mediaId = url.searchParams.get('mediaId') ?? undefined; - const serviceId = url.searchParams.get('serviceId') ?? undefined; - - const activity = getFriendActivity(locals.user.id, { limit, offset, mediaId, serviceId }); - return json({ activity }); -}; diff --git a/src/routes/api/admin/autolink/+server.ts b/src/routes/api/admin/autolink/+server.ts deleted file mode 100644 index e28d3357..00000000 --- a/src/routes/api/admin/autolink/+server.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { randomBytes } from 'crypto'; -import { getServiceConfig, autoDiscoverAndLink, autoLinkJellyfinServices } from '$lib/server/services'; -import { getAllUsers, getUserCredentialForService, upsertUserCredential } from '$lib/server/auth'; -import { registry } from '$lib/adapters/registry'; -import type { RequestHandler } from './$types'; - -// --------------------------------------------------------------------------- -// GET /api/admin/autolink?serviceId=xxx -// Preview discovered external users and their match/link status against Nexus users. -// --------------------------------------------------------------------------- - -export const GET: RequestHandler = async ({ url, locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const serviceId = url.searchParams.get('serviceId'); - if (!serviceId) return json({ error: 'Missing serviceId' }, { status: 400 }); - - const config = getServiceConfig(serviceId); - if (!config) return json({ error: 'Service not found' }, { status: 404 }); - - const adapter = registry.get(config.type); - if (!adapter) return json({ error: `No adapter registered for type: ${config.type}` }, { status: 400 }); - if (!adapter.getUsers) return json({ error: `Adapter ${config.type} does not support getUsers` }, { status: 400 }); - - const [externalUsers, nexusUsers] = await Promise.all([ - adapter.getUsers(config), - Promise.resolve(getAllUsers()) - ]); - - // Build a map: externalUserId → nexus user (for already-linked users) - const linkedMap = new Map(); - for (const nexusUser of nexusUsers) { - const cred = getUserCredentialForService(nexusUser.id, serviceId); - if (cred?.externalUserId) { - linkedMap.set(cred.externalUserId, { - nexusUserId: nexusUser.id, - nexusUsername: nexusUser.username - }); - } - } - - // Build a map: lowercase username → nexus user (for auto-match by username) - const usernameMap = new Map(); - for (const nexusUser of nexusUsers) { - usernameMap.set(nexusUser.username.toLowerCase(), { - nexusUserId: nexusUser.id, - nexusUsername: nexusUser.username - }); - } - - const preview = externalUsers.map((extUser) => { - const linked = linkedMap.get(extUser.externalId); - if (linked) { - return { - externalId: extUser.externalId, - externalUsername: extUser.username, - isAdmin: extUser.isAdmin ?? false, - status: 'already-linked' as const, - nexusUsername: linked.nexusUsername, - nexusUserId: linked.nexusUserId - }; - } - - const match = usernameMap.get(extUser.username.toLowerCase()); - if (match) { - return { - externalId: extUser.externalId, - externalUsername: extUser.username, - isAdmin: extUser.isAdmin ?? false, - status: 'match' as const, - nexusUsername: match.nexusUsername, - nexusUserId: match.nexusUserId - }; - } - - return { - externalId: extUser.externalId, - externalUsername: extUser.username, - isAdmin: extUser.isAdmin ?? false, - status: 'no-match' as const - }; - }); - - return json(preview); -}; - -// --------------------------------------------------------------------------- -// POST /api/admin/autolink — Execute auto-linking -// Body: { serviceId: string, mappings?: [{ externalId: string, nexusUserId: string }] } -// --------------------------------------------------------------------------- - -export const POST: RequestHandler = async ({ request, locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const body = await request.json(); - const { serviceId, mappings } = body as { - serviceId: string; - mappings?: Array<{ externalId: string; nexusUserId: string }>; - }; - - if (!serviceId) return json({ error: 'Missing serviceId' }, { status: 400 }); - - // No mappings — use auto-discovery by username matching - if (!mappings || mappings.length === 0) { - try { - const results = await autoDiscoverAndLink(serviceId); - return json({ results }); - } catch (e) { - console.error('[autolink] autoDiscoverAndLink error', e); - return json({ error: e instanceof Error ? e.message : String(e) }, { status: 500 }); - } - } - - // Explicit mappings provided — execute each one - const config = getServiceConfig(serviceId); - if (!config) return json({ error: 'Service not found' }, { status: 404 }); - - const adapter = registry.get(config.type); - if (!adapter) return json({ error: `No adapter registered for type: ${config.type}` }, { status: 400 }); - if (!adapter.getUsers) return json({ error: `Adapter ${config.type} does not support getUsers` }, { status: 400 }); - - // Fetch external users once so we can resolve username from externalId - let externalUsers: Awaited>; - try { - externalUsers = await adapter.getUsers!(config); - } catch (e) { - return json({ error: `Failed to fetch external users: ${e instanceof Error ? e.message : String(e)}` }, { status: 500 }); - } - - const extUserMap = new Map(externalUsers.map((u) => [u.externalId, u])); - const nexusUsers = getAllUsers(); - const nexusUserMap = new Map(nexusUsers.map((u) => [u.id, u])); - - const results = []; - - for (const mapping of mappings) { - const extUser = extUserMap.get(mapping.externalId); - const nexusUser = nexusUserMap.get(mapping.nexusUserId); - - if (!extUser) { - results.push({ - externalId: mapping.externalId, - nexusUserId: mapping.nexusUserId, - status: 'error' as const, - error: 'External user not found' - }); - continue; - } - - if (!nexusUser) { - results.push({ - externalId: mapping.externalId, - externalUsername: extUser.username, - nexusUserId: mapping.nexusUserId, - status: 'error' as const, - error: 'Nexus user not found' - }); - continue; - } - - // Check if already linked - const existing = getUserCredentialForService(nexusUser.id, serviceId); - if (existing?.externalUserId === mapping.externalId) { - results.push({ - externalId: mapping.externalId, - externalUsername: extUser.username, - nexusUserId: nexusUser.id, - nexusUsername: nexusUser.username, - status: 'already-linked' as const - }); - continue; - } - - try { - if (adapter.resetPassword && adapter.authenticateUser) { - const tempPw = randomBytes(24).toString('base64url'); - await adapter.resetPassword(config, extUser.externalId, tempPw); - const cred = await adapter.authenticateUser(config, extUser.username, tempPw); - upsertUserCredential(nexusUser.id, serviceId, cred); - - // Cascade dependent service links (e.g. Overseerr via Jellyfin token) - await autoLinkJellyfinServices(nexusUser.id); - - results.push({ - externalId: mapping.externalId, - externalUsername: extUser.username, - nexusUserId: nexusUser.id, - nexusUsername: nexusUser.username, - status: 'linked' as const - }); - } else { - // Adapter supports getUsers but not reset/auth — store by ID only if possible - results.push({ - externalId: mapping.externalId, - externalUsername: extUser.username, - nexusUserId: nexusUser.id, - nexusUsername: nexusUser.username, - status: 'error' as const, - error: 'Adapter does not support password reset or authentication' - }); - } - } catch (e) { - results.push({ - externalId: mapping.externalId, - externalUsername: extUser.username, - nexusUserId: nexusUser.id, - nexusUsername: nexusUser.username, - status: 'error' as const, - error: e instanceof Error ? e.message : String(e) - }); - } - } - - return json({ results }); -}; diff --git a/src/routes/api/admin/content/+server.ts b/src/routes/api/admin/content/+server.ts deleted file mode 100644 index 8968a4e6..00000000 --- a/src/routes/api/admin/content/+server.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { getRawDb } from '$lib/db'; -import type { RequestHandler } from './$types'; - -/** - * GET /api/admin/content - * Content summary: counts by type, by service, recent additions, gap counts. - */ -export const GET: RequestHandler = async ({ locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const db = getRawDb(); - - const byType = db.prepare(` - SELECT type, COUNT(*) as count FROM media_items GROUP BY type ORDER BY count DESC - `).all() as { type: string; count: number }[]; - - const byService = db.prepare(` - SELECT mi.service_id as serviceId, s.name as serviceName, s.type as serviceType, COUNT(*) as count - FROM media_items mi - LEFT JOIN services s ON s.id = mi.service_id - GROUP BY mi.service_id - ORDER BY count DESC - `).all() as { serviceId: string; serviceName: string; serviceType: string; count: number }[]; - - const recent = db.prepare(` - SELECT id, title, type, poster, service_id as serviceId, cached_at as cachedAt - FROM media_items - ORDER BY cached_at DESC - LIMIT 20 - `).all() as { id: string; title: string; type: string; poster: string | null; serviceId: string; cachedAt: string }[]; - - const missingPoster = (db.prepare(`SELECT COUNT(*) as count FROM media_items WHERE poster IS NULL OR poster = ''`).get() as any)?.count ?? 0; - const missingDescription = (db.prepare(`SELECT COUNT(*) as count FROM media_items WHERE description IS NULL OR description = ''`).get() as any)?.count ?? 0; - const totalItems = (db.prepare(`SELECT COUNT(*) as count FROM media_items`).get() as any)?.count ?? 0; - - // Per-type play time from play_sessions - const playTimeByType = db.prepare(` - SELECT media_type as type, COALESCE(SUM(duration_ms), 0) as playTimeMs - FROM play_sessions - GROUP BY media_type - `).all() as { type: string; playTimeMs: number }[]; - - return json({ - totalItems, - byType, - byService, - recent, - gaps: { missingPoster, missingDescription }, - playTimeByType - }); -}; diff --git a/src/routes/api/admin/downloads/+server.ts b/src/routes/api/admin/downloads/+server.ts deleted file mode 100644 index 8c043af8..00000000 --- a/src/routes/api/admin/downloads/+server.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getEnabledConfigs } from '$lib/server/services'; -import { registry } from '$lib/adapters/registry'; -import { withCache } from '$lib/server/cache'; - -export const GET: RequestHandler = async ({ locals, url }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const statusFilter = url.searchParams.get('status') ?? 'all'; - - const items = await withCache(`admin-downloads:${statusFilter}`, 10_000, async () => { - const configs = getEnabledConfigs(); - const all: any[] = []; - - await Promise.allSettled( - configs.map(async (config) => { - const adapter = registry.get(config.type); - if (!adapter?.getQueue) return; - const queue = await adapter.getQueue(config); - for (const item of queue) { - item.metadata = { ...item.metadata, serviceName: config.name, serviceId: config.id }; - all.push(item); - } - }) - ); - - return all.sort((a, b) => { - const aFailed = a.metadata?.queueStatus === 'failed' ? 0 : 1; - const bFailed = b.metadata?.queueStatus === 'failed' ? 0 : 1; - if (aFailed !== bFailed) return aFailed - bFailed; - return (b.metadata?.downloadProgress ?? 0) - (a.metadata?.downloadProgress ?? 0); - }).filter((item) => { - if (statusFilter === 'all') return true; - if (statusFilter === 'active') return ['downloading', 'queued', 'paused'].includes(item.metadata?.queueStatus); - if (statusFilter === 'failed') return item.metadata?.queueStatus === 'failed'; - return true; - }); - }); - - return json(items); -}; diff --git a/src/routes/api/admin/downloads/[serviceId]/[queueId]/+server.ts b/src/routes/api/admin/downloads/[serviceId]/[queueId]/+server.ts deleted file mode 100644 index 0ca6ec62..00000000 --- a/src/routes/api/admin/downloads/[serviceId]/[queueId]/+server.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getServiceConfigs } from '$lib/server/services'; - -export const POST: RequestHandler = async ({ params, request, locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const { serviceId, queueId } = params; - const config = getServiceConfigs().find((s) => s.id === serviceId); - if (!config) return json({ error: 'Service not found' }, { status: 404 }); - - const body = await request.json(); - const action = body.action as string; - if (!['retry', 'remove', 'blocklist'].includes(action)) { - return json({ error: 'Unknown action' }, { status: 400 }); - } - - const apiVersion = config.type === 'lidarr' ? 'v1' : 'v3'; - const url = new URL(`${config.url}/api/${apiVersion}/queue/${queueId}`); - url.searchParams.set('apikey', config.apiKey ?? ''); - url.searchParams.set('removeFromClient', 'true'); - url.searchParams.set('blocklist', action === 'blocklist' ? 'true' : 'false'); - - try { - const res = await fetch(url.toString(), { method: 'DELETE', signal: AbortSignal.timeout(8000) }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return json({ success: true }); - } catch (e) { - return json({ error: e instanceof Error ? e.message : 'Failed' }, { status: 500 }); - } -}; diff --git a/src/routes/api/admin/invites/+server.ts b/src/routes/api/admin/invites/+server.ts deleted file mode 100644 index ba54494f..00000000 --- a/src/routes/api/admin/invites/+server.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { createInviteLink, deleteInviteLink, getInviteLinks } from '$lib/server/auth'; -import type { RequestHandler } from './$types'; - -// GET /api/admin/invites — List all invite links -export const GET: RequestHandler = async ({ locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - const invites = getInviteLinks(); - return json(invites); -}; - -// POST /api/admin/invites — Create a new invite link -export const POST: RequestHandler = async ({ request, locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const body = await request.json().catch(() => ({})); - const maxUses = body.maxUses ?? 1; - const expiresInHours = body.expiresInHours ?? null; - - const code = createInviteLink(locals.user.id, { - maxUses, - expiresInHours: expiresInHours ?? undefined - }); - - return json({ code, maxUses, expiresInHours }); -}; - -// DELETE /api/admin/invites?code=xxx — Delete an invite link -export const DELETE: RequestHandler = async ({ url, locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const code = url.searchParams.get('code'); - if (!code) return json({ error: 'Missing code parameter' }, { status: 400 }); - - deleteInviteLink(code); - return json({ ok: true }); -}; diff --git a/src/routes/api/admin/migrate/jellyfin/+server.ts b/src/routes/api/admin/migrate/jellyfin/+server.ts deleted file mode 100644 index ad27b1fd..00000000 --- a/src/routes/api/admin/migrate/jellyfin/+server.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { getJellyfinUsers } from '$lib/server/services'; -import { createUser, getUserByUsername, upsertUserCredential } from '$lib/server/auth'; -import { randomBytes } from 'crypto'; -import type { RequestHandler } from './$types'; - -// GET /api/admin/migrate/jellyfin — Preview: list all Jellyfin users -export const GET: RequestHandler = async ({ locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - try { - const jfUsers = await getJellyfinUsers(); - return json(jfUsers); - } catch (e) { - console.error('[API] Jellyfin user list error', e); - return json({ error: 'Failed to fetch Jellyfin users' }, { status: 500 }); - } -}; - -// POST /api/admin/migrate/jellyfin — Import Jellyfin users into Nexus -export const POST: RequestHandler = async ({ request, locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const body = await request.json().catch(() => ({})); - // body.users = array of { externalId, username, serviceId } to import - // If not provided, import ALL Jellyfin users - let toImport: Array<{ externalId: string; username: string; serviceId: string }>; - - if (body.users && Array.isArray(body.users)) { - toImport = body.users; - } else { - const jfUsers = await getJellyfinUsers(); - toImport = jfUsers.map((u) => ({ - externalId: u.externalId, - username: u.username, - serviceId: u.serviceId - })); - } - - const results: Array<{ username: string; status: string; nexusId?: string }> = []; - - for (const jfUser of toImport) { - try { - // Check if Nexus user with this username already exists - const existing = getUserByUsername(jfUser.username); - if (existing) { - // Just link the credential if not already linked - upsertUserCredential(existing.id, jfUser.serviceId, { - externalUserId: jfUser.externalId, - externalUsername: jfUser.username - }); - results.push({ username: jfUser.username, status: 'linked', nexusId: existing.id }); - continue; - } - - // Create a new Nexus account with a random password - // User will need an invite link to set their own password, or admin resets it - const tempPassword = randomBytes(24).toString('base64url'); - const nexusId = createUser( - jfUser.username, - jfUser.username, // displayName = username initially - tempPassword, - false, // not admin - { authProvider: 'jellyfin', externalId: jfUser.externalId } - ); - - // Link the Jellyfin credential - upsertUserCredential(nexusId, jfUser.serviceId, { - externalUserId: jfUser.externalId, - externalUsername: jfUser.username - }); - - results.push({ username: jfUser.username, status: 'created', nexusId }); - } catch (e) { - results.push({ username: jfUser.username, status: `error: ${String(e)}` }); - } - } - - return json({ imported: results.length, results }); -}; diff --git a/src/routes/api/admin/quality/+server.ts b/src/routes/api/admin/quality/+server.ts deleted file mode 100644 index 1f19debe..00000000 --- a/src/routes/api/admin/quality/+server.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getEnabledConfigs } from '$lib/server/services'; -import { withCache } from '$lib/server/cache'; - -export const GET: RequestHandler = async ({ locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const stats = await withCache('admin-quality-stats', 300_000, async () => { - const configs = getEnabledConfigs(); - const breakdown: Record = {}; - const byService: Record }> = {}; - let total = 0; - let withFile = 0; - - for (const config of configs) { - // Only check *arr services that manage files - if (!['radarr', 'sonarr', 'lidarr'].includes(config.type)) continue; - - try { - const apiVersion = config.type === 'lidarr' ? 'v1' : 'v3'; - const endpoint = config.type === 'radarr' ? 'movie' : config.type === 'sonarr' ? 'series' : 'album'; - const url = new URL(`${config.url}/api/${apiVersion}/${endpoint}`); - url.searchParams.set('apikey', config.apiKey ?? ''); - - const res = await fetch(url.toString(), { signal: AbortSignal.timeout(15000) }); - if (!res.ok) continue; - const items = await res.json(); - - const svcStats = { total: 0, withFile: 0, qualities: {} as Record }; - - for (const item of items) { - svcStats.total++; - total++; - - const hasFile = - config.type === 'radarr' - ? item.hasFile - : config.type === 'sonarr' - ? (item.statistics?.episodeFileCount ?? 0) > 0 - : (item.statistics?.trackFileCount ?? 0) > 0; - - if (hasFile) { - svcStats.withFile++; - withFile++; - - let quality = 'Unknown'; - if (config.type === 'radarr' && item.movieFile?.quality?.quality?.name) { - quality = item.movieFile.quality.quality.name; - } else if (config.type === 'sonarr') { - quality = item.qualityProfileId ? `Profile ${item.qualityProfileId}` : 'Unknown'; - } else if (config.type === 'lidarr') { - quality = item.qualityProfileId ? `Profile ${item.qualityProfileId}` : 'Unknown'; - } - - breakdown[quality] = (breakdown[quality] ?? 0) + 1; - svcStats.qualities[quality] = (svcStats.qualities[quality] ?? 0) + 1; - } - } - - byService[config.name] = svcStats; - } catch { - continue; - } - } - - // Group by resolution tier for the summary - const tiers: Record = { '4K': 0, '1080p': 0, '720p': 0, SD: 0, Other: 0 }; - for (const [quality, count] of Object.entries(breakdown)) { - const q = quality.toLowerCase(); - if (q.includes('2160') || q.includes('4k') || q.includes('uhd')) tiers['4K'] += count; - else if (q.includes('1080')) tiers['1080p'] += count; - else if (q.includes('720')) tiers['720p'] += count; - else if (q.includes('480') || q.includes('576') || q.includes('sd')) tiers['SD'] += count; - else tiers['Other'] += count; - } - - return { total, withFile, missing: total - withFile, tiers, breakdown, byService }; - }); - - return json(stats); -}; diff --git a/src/routes/api/admin/recommendations/+server.ts b/src/routes/api/admin/recommendations/+server.ts deleted file mode 100644 index 7f7d6ff2..00000000 --- a/src/routes/api/admin/recommendations/+server.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { getRawDb } from '$lib/db'; -import { recRegistry } from '$lib/server/recommendations/registry'; -import { invalidatePrefix } from '$lib/server/cache'; -import { DEFAULT_PROFILE } from '$lib/server/recommendations/types'; -import type { RequestHandler } from '@sveltejs/kit'; - -export const GET: RequestHandler = async ({ locals }) => { - const user = locals.user; - if (!user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const raw = getRawDb(); - - const dummyCtx = { - userId: user.id, - limit: 0, - profile: DEFAULT_PROFILE, - excludeIds: new Set() - }; - - const providers = recRegistry.all().map((p) => ({ - id: p.id, - displayName: p.displayName, - category: p.category, - ready: p.isReady(dummyCtx) - })); - - const cacheCount = raw.prepare(`SELECT COUNT(*) as c FROM recommendation_cache`).get() as { c: number }; - const affinityCount = raw.prepare(`SELECT COUNT(DISTINCT user_id) as c FROM user_genre_affinity`).get() as { c: number }; - const hiddenCount = raw.prepare(`SELECT COUNT(*) as c FROM user_hidden_items`).get() as { c: number }; - - const settings = raw.prepare(`SELECT key, value FROM app_settings WHERE key LIKE 'rec:%'`).all() as Array<{ key: string; value: string }>; - const weights: Record = {}; - for (const s of settings) { - weights[s.key] = s.value; - } - - return json({ - providers, - stats: { - cachedResults: cacheCount.c, - usersWithAffinity: affinityCount.c, - hiddenItems: hiddenCount.c - }, - globalWeights: weights - }); -}; - -export const POST: RequestHandler = async ({ request, locals }) => { - const user = locals.user; - if (!user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const body = await request.json(); - const { action, weights } = body as { action?: string; weights?: Record }; - - const raw = getRawDb(); - - if (action === 'recompute') { - raw.prepare(`DELETE FROM recommendation_cache`).run(); - invalidatePrefix('rec-rows:'); - return json({ ok: true, message: 'Cache cleared, recommendations will be recomputed' }); - } - - if (weights) { - for (const [key, value] of Object.entries(weights)) { - raw.prepare( - `INSERT INTO app_settings (key, value) VALUES (?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value` - ).run(`rec:${key}`, String(value)); - } - return json({ ok: true }); - } - - return json({ error: 'Unknown action' }, { status: 400 }); -}; diff --git a/src/routes/api/admin/settings/+server.ts b/src/routes/api/admin/settings/+server.ts deleted file mode 100644 index a656c0ae..00000000 --- a/src/routes/api/admin/settings/+server.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { getAllSettings, setSetting } from '$lib/server/auth'; -import type { RequestHandler } from './$types'; - -export const GET: RequestHandler = async ({ locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - return json(getAllSettings()); -}; - -export const PUT: RequestHandler = async ({ request, locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const { key, value } = await request.json(); - if (!key || typeof key !== 'string' || typeof value !== 'string') { - return json({ error: 'Missing key or value' }, { status: 400 }); - } - - setSetting(key, value); - return json({ ok: true }); -}; diff --git a/src/routes/api/admin/stats/+server.ts b/src/routes/api/admin/stats/+server.ts deleted file mode 100644 index 2ba1e778..00000000 --- a/src/routes/api/admin/stats/+server.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { getActiveUserIds, getOrComputeStats } from '$lib/server/stats-engine'; -import type { RequestHandler } from './$types'; - -/** - * GET /api/admin/stats?period=month:2026-03 - * Server-wide aggregate stats (admin only). - */ -export const GET: RequestHandler = async ({ url, locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const period = url.searchParams.get('period') ?? 'alltime'; - const userIds = getActiveUserIds(); - - let totalPlayTimeMs = 0; - let totalSessions = 0; - let totalItems = 0; - let totalPageViews = 0; - const genreAgg = new Map(); - const deviceAgg = new Map(); - - for (const uid of userIds) { - const stats = getOrComputeStats(uid, period, 'all', 600_000); - totalPlayTimeMs += stats.totalPlayTimeMs; - totalSessions += stats.totalSessions; - totalItems += stats.totalItems; - totalPageViews += stats.totalPageViews; - - for (const g of stats.topGenres) { - genreAgg.set(g.genre, (genreAgg.get(g.genre) ?? 0) + g.playTimeMs); - } - for (const d of stats.topDevices) { - deviceAgg.set(d.name, (deviceAgg.get(d.name) ?? 0) + d.playTimeMs); - } - } - - const topGenres = [...genreAgg.entries()] - .map(([genre, playTimeMs]) => ({ genre, playTimeMs })) - .sort((a, b) => b.playTimeMs - a.playTimeMs) - .slice(0, 20); - - const topDevices = [...deviceAgg.entries()] - .map(([name, playTimeMs]) => ({ name, playTimeMs })) - .sort((a, b) => b.playTimeMs - a.playTimeMs) - .slice(0, 10); - - return json({ - period, - activeUsers: userIds.length, - totalPlayTimeMs, - totalSessions, - totalItems, - totalPageViews, - topGenres, - topDevices - }); -}; diff --git a/src/routes/api/admin/stats/timeline/+server.ts b/src/routes/api/admin/stats/timeline/+server.ts deleted file mode 100644 index b79815ec..00000000 --- a/src/routes/api/admin/stats/timeline/+server.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { getRawDb } from '$lib/db'; -import type { RequestHandler } from './$types'; - -/** - * GET /api/admin/stats/timeline?days=30 - * Returns daily play time + session counts for the bar chart. - */ -export const GET: RequestHandler = async ({ url, locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const days = Math.min(parseInt(url.searchParams.get('days') ?? '30') || 30, 90); - const db = getRawDb(); - const since = Date.now() - days * 86400000; - - const rows = db.prepare(` - SELECT - date(started_at / 1000, 'unixepoch', 'localtime') as date, - COALESCE(SUM(duration_ms), 0) as playTimeMs, - COUNT(*) as sessions - FROM play_sessions - WHERE started_at >= ? - GROUP BY date - ORDER BY date ASC - `).all(since) as { date: string; playTimeMs: number; sessions: number }[]; - - return json(rows); -}; diff --git a/src/routes/api/admin/stats/users/+server.ts b/src/routes/api/admin/stats/users/+server.ts deleted file mode 100644 index 8595df14..00000000 --- a/src/routes/api/admin/stats/users/+server.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { getRawDb } from '$lib/db'; -import type { RequestHandler } from './$types'; - -/** - * GET /api/admin/stats/users - * Per-user play time summary for admin analytics. - */ -export const GET: RequestHandler = async ({ locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const db = getRawDb(); - - const rows = db.prepare(` - SELECT - ps.user_id as userId, - u.display_name as displayName, - u.username, - COALESCE(SUM(ps.duration_ms), 0) as totalPlayTimeMs, - COUNT(*) as totalSessions, - MAX(ps.started_at) as lastActive - FROM play_sessions ps - LEFT JOIN users u ON u.id = ps.user_id - GROUP BY ps.user_id - ORDER BY totalPlayTimeMs DESC - `).all() as { userId: string; displayName: string; username: string; totalPlayTimeMs: number; totalSessions: number; lastActive: number }[]; - - return json(rows); -}; diff --git a/src/routes/api/admin/subtitles/+server.ts b/src/routes/api/admin/subtitles/+server.ts deleted file mode 100644 index df420309..00000000 --- a/src/routes/api/admin/subtitles/+server.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { json, error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getEnabledConfigs } from '$lib/server/services'; -import { getProviderStatus, getLanguageProfiles, getSystemHistory } from '$lib/adapters/bazarr'; - -export const GET: RequestHandler = async ({ url, locals }) => { - if (!locals.user?.isAdmin) throw error(403, 'Admin only'); - - const bazarrConfigs = getEnabledConfigs().filter((c) => c.type === 'bazarr'); - if (bazarrConfigs.length === 0) { - return json({ providers: [], profiles: [], history: { events: [], total: 0 } }); - } - - const config = bazarrConfigs[0]; - const section = url.searchParams.get('section'); - const page = parseInt(url.searchParams.get('page') ?? '1', 10); - const limit = parseInt(url.searchParams.get('limit') ?? '50', 10); - - if (section === 'providers') { - return json({ providers: await getProviderStatus(config) }); - } - if (section === 'profiles') { - return json({ profiles: await getLanguageProfiles(config) }); - } - if (section === 'history') { - return json(await getSystemHistory(config, { page, limit })); - } - - // Return all sections - const [providers, profiles, history] = await Promise.all([ - getProviderStatus(config), - getLanguageProfiles(config), - getSystemHistory(config, { page, limit }) - ]); - - return json({ providers, profiles, history }); -}; diff --git a/src/routes/api/admin/subtitles/providers/+server.ts b/src/routes/api/admin/subtitles/providers/+server.ts deleted file mode 100644 index 5f167332..00000000 --- a/src/routes/api/admin/subtitles/providers/+server.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { json, error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getEnabledConfigs } from '$lib/server/services'; -import { getProviderStatus, resetProviders } from '$lib/adapters/bazarr'; - -/** - * GET /api/admin/subtitles/providers - * - * Returns subtitle provider status from Bazarr (active, throttled, error). - */ -export const GET: RequestHandler = async ({ locals }) => { - if (!locals.user?.isAdmin) throw error(403, 'Admin only'); - - const bazarrConfigs = getEnabledConfigs().filter((c) => c.type === 'bazarr'); - if (bazarrConfigs.length === 0) { - return json({ providers: [] }); - } - - const config = bazarrConfigs[0]; - const providers = await getProviderStatus(config); - return json({ providers }); -}; - -/** - * POST /api/admin/subtitles/providers - * - * Reset throttled subtitle providers. - * Body: { action: 'reset' } - */ -export const POST: RequestHandler = async ({ request, locals }) => { - if (!locals.user?.isAdmin) throw error(403, 'Admin only'); - - const body = await request.json(); - if (body.action !== 'reset') { - throw error(400, 'Invalid action'); - } - - const bazarrConfigs = getEnabledConfigs().filter((c) => c.type === 'bazarr'); - if (bazarrConfigs.length === 0) { - throw error(404, 'No Bazarr service configured'); - } - - const config = bazarrConfigs[0]; - - try { - await resetProviders(config); - return json({ success: true }); - } catch (e) { - console.error('[admin/subtitles/providers] reset error:', e); - throw error(502, e instanceof Error ? e.message : 'Reset failed'); - } -}; diff --git a/src/routes/api/admin/system/+server.ts b/src/routes/api/admin/system/+server.ts deleted file mode 100644 index bd43dea0..00000000 --- a/src/routes/api/admin/system/+server.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { getRawDb } from '$lib/db'; -import { getConnectedUserCount, getOnlineUserIds } from '$lib/server/ws'; -import { existsSync, statSync } from 'fs'; -import type { RequestHandler } from './$types'; - -/** - * GET /api/admin/system - * System info: DB file size, table row counts, WS connections, etc. - */ -export const GET: RequestHandler = async ({ locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const db = getRawDb(); - const dbPath = process.env.DATABASE_URL || './nexus.db'; - - // DB file size - let dbSizeBytes = 0; - try { - if (existsSync(dbPath)) dbSizeBytes = statSync(dbPath).size; - } catch { /* ignore */ } - - // Row counts for key tables. The legacy `activity` table was dropped on - // 2026-04-17 (migration 0008); `play_sessions` is the canonical progress - // store and is already listed below. - const tables = ['users', 'media_items', 'play_sessions', 'media_actions', 'interaction_events', 'services', 'sessions', 'stats_rollups']; - const rowCounts: Record = {}; - for (const t of tables) { - try { - const row = db.prepare(`SELECT COUNT(*) as count FROM ${t}`).get() as any; - rowCounts[t] = row?.count ?? 0; - } catch { - rowCounts[t] = 0; - } - } - - // WebSocket info - const onlineUserIds = [...getOnlineUserIds()]; - - // App settings - let appSettings: Record = {}; - try { - const rows = db.prepare(`SELECT key, value FROM app_settings`).all() as { key: string; value: string }[]; - appSettings = Object.fromEntries(rows.map(r => [r.key, r.value])); - } catch { /* ignore */ } - - return json({ - db: { path: dbPath, sizeBytes: dbSizeBytes, rowCounts }, - ws: { connectedUsers: getConnectedUserCount(), onlineUserIds }, - appSettings - }); -}; diff --git a/src/routes/api/admin/system/cache/+server.ts b/src/routes/api/admin/system/cache/+server.ts deleted file mode 100644 index 3abbaa51..00000000 --- a/src/routes/api/admin/system/cache/+server.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { invalidateAll, invalidatePrefix } from '$lib/server/cache'; -import type { RequestHandler } from './$types'; - -/** - * POST /api/admin/system/cache/clear - * Clear all cache or a prefix. Body: { prefix?: string } - */ -export const POST: RequestHandler = async ({ request, locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const body = await request.json().catch(() => ({})); - const prefix = body.prefix as string | undefined; - - if (prefix) { - invalidatePrefix(prefix); - return json({ cleared: prefix }); - } - - invalidateAll(); - return json({ cleared: 'all' }); -}; diff --git a/src/routes/api/admin/system/stats/+server.ts b/src/routes/api/admin/system/stats/+server.ts deleted file mode 100644 index 591d8797..00000000 --- a/src/routes/api/admin/system/stats/+server.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { getActiveUserIds, rebuildStatsForUser } from '$lib/server/stats-engine'; -import type { RequestHandler } from './$types'; - -/** - * POST /api/admin/system/stats/rebuild - * Trigger full stats rebuild for all active users. - */ -export const POST: RequestHandler = async ({ locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const userIds = getActiveUserIds(); - let rebuilt = 0; - - for (const uid of userIds) { - try { - rebuildStatsForUser(uid); - rebuilt++; - } catch (e) { - console.error(`[admin] Failed to rebuild stats for ${uid}:`, e); - } - } - - return json({ rebuilt, total: userIds.length }); -}; diff --git a/src/routes/api/admin/users/+server.ts b/src/routes/api/admin/users/+server.ts deleted file mode 100644 index ba540404..00000000 --- a/src/routes/api/admin/users/+server.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { createUser, deleteUser, getAllUsers, updateUser } from '$lib/server/auth'; -import type { RequestHandler } from './$types'; - -// GET /api/admin/users — List all users -export const GET: RequestHandler = async ({ locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - const users = getAllUsers(); - return json(users); -}; - -// POST /api/admin/users — Create a user (admin action) -export const POST: RequestHandler = async ({ request, locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const body = await request.json(); - const { username, displayName, password, isAdmin } = body; - - if (!username || !displayName || !password) { - return json({ error: 'Missing required fields' }, { status: 400 }); - } - - try { - const id = createUser(username, displayName, password, isAdmin ?? false, { - status: 'active', - forcePasswordReset: true - }); - return json({ id, username, displayName, isAdmin: isAdmin ?? false }); - } catch (e) { - const msg = String(e); - if (msg.includes('UNIQUE')) { - return json({ error: 'Username already taken' }, { status: 409 }); - } - return json({ error: 'Failed to create user' }, { status: 500 }); - } -}; - -// PATCH /api/admin/users — Update a user -export const PATCH: RequestHandler = async ({ request, locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const body = await request.json(); - const { id, displayName, isAdmin } = body; - - if (!id) return json({ error: 'Missing user id' }, { status: 400 }); - - try { - updateUser(id, { displayName, isAdmin }); - return json({ ok: true }); - } catch (e) { - console.error('[API] user update error', e); - return json({ error: 'Failed to update user' }, { status: 500 }); - } -}; - -// DELETE /api/admin/users?id=xxx — Delete a user -export const DELETE: RequestHandler = async ({ url, locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const id = url.searchParams.get('id'); - if (!id) return json({ error: 'Missing id parameter' }, { status: 400 }); - - // Prevent self-deletion - if (id === locals.user.id) { - return json({ error: 'Cannot delete your own account' }, { status: 400 }); - } - - deleteUser(id); - return json({ ok: true }); -}; diff --git a/src/routes/api/admin/users/[id]/approve/+server.ts b/src/routes/api/admin/users/[id]/approve/+server.ts deleted file mode 100644 index f0279357..00000000 --- a/src/routes/api/admin/users/[id]/approve/+server.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { approveUser } from '$lib/server/auth'; -import type { RequestHandler } from './$types'; - -export const PUT: RequestHandler = async ({ params, locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - approveUser(params.id); - return json({ ok: true }); -}; diff --git a/src/routes/api/admin/users/[id]/deny/+server.ts b/src/routes/api/admin/users/[id]/deny/+server.ts deleted file mode 100644 index 1c7b8ce4..00000000 --- a/src/routes/api/admin/users/[id]/deny/+server.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { deleteUser } from '$lib/server/auth'; -import type { RequestHandler } from './$types'; - -export const DELETE: RequestHandler = async ({ params, locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - deleteUser(params.id); - return json({ ok: true }); -}; diff --git a/src/routes/api/admin/users/[id]/force-reset/+server.ts b/src/routes/api/admin/users/[id]/force-reset/+server.ts deleted file mode 100644 index ff9abcbc..00000000 --- a/src/routes/api/admin/users/[id]/force-reset/+server.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { setForcePasswordReset } from '$lib/server/auth'; -import type { RequestHandler } from './$types'; - -export const PUT: RequestHandler = async ({ params, request, locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const { force } = await request.json(); - if (typeof force !== 'boolean') return json({ error: 'force must be boolean' }, { status: 400 }); - - setForcePasswordReset(params.id, force); - return json({ ok: true }); -}; diff --git a/src/routes/api/admin/users/[id]/reset-password/+server.ts b/src/routes/api/admin/users/[id]/reset-password/+server.ts deleted file mode 100644 index 7f82ad75..00000000 --- a/src/routes/api/admin/users/[id]/reset-password/+server.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { resetUserPassword } from '$lib/server/auth'; -import type { RequestHandler } from './$types'; - -export const PUT: RequestHandler = async ({ params, request, locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const { password } = await request.json(); - if (!password || typeof password !== 'string' || password.length < 6) { - return json({ error: 'Password must be at least 6 characters' }, { status: 400 }); - } - - resetUserPassword(params.id, password); - return json({ ok: true }); -}; diff --git a/src/routes/api/admin/users/online/+server.ts b/src/routes/api/admin/users/online/+server.ts deleted file mode 100644 index f08a73e0..00000000 --- a/src/routes/api/admin/users/online/+server.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { getOnlineUserIds } from '$lib/server/ws'; -import type { RequestHandler } from './$types'; - -/** - * GET /api/admin/users/online - * Returns set of currently online user IDs. - */ -export const GET: RequestHandler = async ({ locals }) => { - if (!locals.user?.isAdmin) return json({ error: 'Forbidden' }, { status: 403 }); - - const onlineIds = [...getOnlineUserIds()]; - return json({ userIds: onlineIds, count: onlineIds.length }); -}; diff --git a/src/routes/api/annotations/+server.ts b/src/routes/api/annotations/+server.ts new file mode 100644 index 00000000..3cb63e56 --- /dev/null +++ b/src/routes/api/annotations/+server.ts @@ -0,0 +1,44 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listAnnotations, addAnnotation } from '$lib/server/annotations'; + +// GET /api/annotations?page= → { annotations: [...] } +export const GET: RequestHandler = ({ url, locals }) => { + if (!locals.user) return json({ annotations: [] }); + const page = url.searchParams.get('page') || ''; + if (!page) return json({ annotations: [] }); + return json({ annotations: listAnnotations(page) }); +}; + +// POST /api/annotations { page_path, anchor_selector, anchor_snippet, anchor_offset_x, anchor_offset_y, body } +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.user) throw error(401, 'Unauthorized'); + let p: Record; + try { + p = await request.json(); + } catch { + throw error(400, 'bad json'); + } + const page_path = String(p.page_path || '').slice(0, 512); + const anchor_selector = String(p.anchor_selector || '').slice(0, 1024); + const body = String(p.body || '') + .trim() + .slice(0, 2000); + if (!page_path || !anchor_selector || !body) { + throw error(400, 'page_path, anchor_selector and body are required'); + } + const clamp01 = (v: unknown) => { + const n = Number(v); + return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : 0.5; + }; + const ann = addAnnotation({ + page_path, + anchor_selector, + anchor_snippet: String(p.anchor_snippet || '').slice(0, 200), + anchor_offset_x: clamp01(p.anchor_offset_x), + anchor_offset_y: clamp01(p.anchor_offset_y), + body, + author: locals.user.username || '' + }); + return json({ annotation: ann }, { status: 201 }); +}; diff --git a/src/routes/api/annotations/[id]/+server.ts b/src/routes/api/annotations/[id]/+server.ts new file mode 100644 index 00000000..9e76457a --- /dev/null +++ b/src/routes/api/annotations/[id]/+server.ts @@ -0,0 +1,12 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { deleteAnnotation } from '$lib/server/annotations'; + +// DELETE /api/annotations/ — "ack & remove". Collaborative: any signed-in +// user can clear a note (the hooks API gate already requires a session). +export const DELETE: RequestHandler = ({ params, locals }) => { + if (!locals.user) throw error(401, 'Unauthorized'); + const id = Number(params.id); + if (!Number.isInteger(id)) throw error(400, 'bad id'); + return json({ ok: deleteAnnotation(id) }); +}; diff --git a/src/routes/api/auth/[...all]/+server.ts b/src/routes/api/auth/[...all]/+server.ts new file mode 100644 index 00000000..830050df --- /dev/null +++ b/src/routes/api/auth/[...all]/+server.ts @@ -0,0 +1,10 @@ +import { auth } from '$lib/server/auth/better-auth'; +import { toSvelteKitHandler } from 'better-auth/svelte-kit'; +import type { RequestHandler } from './$types'; + +// Better Auth's endpoints (/api/auth/sign-in/username, /sign-out, /get-session, +// OIDC callbacks, etc.). The specific /api/auth/logout route still wins for the +// legacy logout during the coexistence transition. +const handler = toSvelteKitHandler(auth); +export const GET: RequestHandler = (event) => handler(event); +export const POST: RequestHandler = (event) => handler(event); diff --git a/src/routes/api/auth/logout/+server.ts b/src/routes/api/auth/logout/+server.ts index a0ae4478..75633496 100644 --- a/src/routes/api/auth/logout/+server.ts +++ b/src/routes/api/auth/logout/+server.ts @@ -1,10 +1,31 @@ import { redirect } from '@sveltejs/kit'; +import { eq } from 'drizzle-orm'; import { COOKIE_NAME, deleteSession } from '$lib/server/auth'; +import { auth } from '$lib/server/auth/better-auth'; +import { getDb, schema } from '$lib/db'; import type { RequestHandler } from './$types'; -export const POST: RequestHandler = async ({ cookies }) => { +export const POST: RequestHandler = async ({ request, cookies, locals }) => { + // Log the human out EVERYWHERE — both session systems, every session, both + // cookies — so coexistence can't leave a live session behind (a BA cookie + // whose Set-Cookie path doesn't ride this request, or a stale legacy row). + const userId = locals.user?.id; const token = cookies.get(COOKIE_NAME); if (token) deleteSession(token); cookies.delete(COOKIE_NAME, { path: '/' }); + try { + await auth.api.signOut({ headers: request.headers }); + } catch { + // No active BA session (or already cleared) — nothing to revoke here. + } + // Belt-and-suspenders: clear the BA cookie unconditionally and revoke ALL of + // this user's sessions in both tables (covers a session whose cookie wasn't + // sent on this request). + cookies.delete('better-auth.session_token', { path: '/' }); + if (userId) { + const db = getDb(); + db.delete(schema.sessions).where(eq(schema.sessions.userId, userId)).run(); + db.delete(schema.authSessions).where(eq(schema.authSessions.userId, userId)).run(); + } throw redirect(303, '/login'); }; diff --git a/src/routes/api/books/[id]/bookmarks/+server.ts b/src/routes/api/books/[id]/bookmarks/+server.ts deleted file mode 100644 index 0047b086..00000000 --- a/src/routes/api/books/[id]/bookmarks/+server.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { json, error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getDb, schema } from '$lib/db'; -import { eq, and, desc } from 'drizzle-orm'; -import crypto from 'crypto'; - -export const GET: RequestHandler = async ({ params, locals }) => { - if (!locals.user) throw error(401); - const db = getDb(); - const bookmarks = db.select().from(schema.bookBookmarks) - .where(and( - eq(schema.bookBookmarks.userId, locals.user.id), - eq(schema.bookBookmarks.bookId, params.id) - )) - .orderBy(desc(schema.bookBookmarks.createdAt)) - .all(); - return json({ bookmarks }); -}; - -export const POST: RequestHandler = async ({ params, locals, request }) => { - if (!locals.user) throw error(401); - const { cfi, label, serviceId } = await request.json(); - if (!cfi) throw error(400, 'cfi required'); - if (!serviceId) throw error(400, 'serviceId required'); - - const bookmark = { - id: crypto.randomUUID(), - userId: locals.user.id, - bookId: params.id, - serviceId, - cfi, - label: label ?? null, - createdAt: Date.now() - }; - - const db = getDb(); - db.insert(schema.bookBookmarks).values(bookmark).run(); - return json(bookmark, { status: 201 }); -}; diff --git a/src/routes/api/books/[id]/bookmarks/[bookmarkId]/+server.ts b/src/routes/api/books/[id]/bookmarks/[bookmarkId]/+server.ts deleted file mode 100644 index 7e6f2723..00000000 --- a/src/routes/api/books/[id]/bookmarks/[bookmarkId]/+server.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { json, error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getDb, schema } from '$lib/db'; -import { eq, and } from 'drizzle-orm'; - -export const DELETE: RequestHandler = async ({ params, locals }) => { - if (!locals.user) throw error(401); - const db = getDb(); - const existing = db.select().from(schema.bookBookmarks) - .where(and( - eq(schema.bookBookmarks.id, params.bookmarkId), - eq(schema.bookBookmarks.userId, locals.user.id) - )) - .get(); - if (!existing) throw error(404); - - db.delete(schema.bookBookmarks) - .where(eq(schema.bookBookmarks.id, params.bookmarkId)) - .run(); - - return json({ ok: true }); -}; diff --git a/src/routes/api/books/[id]/download/[format]/+server.ts b/src/routes/api/books/[id]/download/[format]/+server.ts deleted file mode 100644 index 253d42af..00000000 --- a/src/routes/api/books/[id]/download/[format]/+server.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getConfigsForMediaType } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { registry } from '$lib/adapters/registry'; - -const MIME_TYPES: Record = { - epub: 'application/epub+zip', - pdf: 'application/pdf', - mobi: 'application/x-mobipocket-ebook', - azw3: 'application/x-mobi8-ebook', - cbz: 'application/x-cbz', - cbr: 'application/x-cbr', - txt: 'text/plain', - rtf: 'application/rtf' -}; - -export const GET: RequestHandler = async ({ params, locals, url }) => { - if (!locals.user) throw error(401); - const config = getConfigsForMediaType('book')[0]; - if (!config) throw error(404, 'No Calibre service configured'); - - const adapter = registry.get(config.type); - const userCred = getUserCredentialForService(locals.user.id, config.id) ?? undefined; - const response = await adapter?.downloadContent?.(config, params.id, params.format, userCred); - if (!response) throw error(500, 'Download not supported'); - - const contentType = MIME_TYPES[params.format.toLowerCase()] ?? 'application/octet-stream'; - const isView = url.searchParams.get('view') === 'true'; - const disposition = isView ? 'inline' : `attachment; filename="book-${params.id}.${params.format}"`; - - return new Response(response.body, { - headers: { - 'Content-Type': contentType, - 'Content-Disposition': disposition, - 'Cache-Control': 'private, max-age=3600', - 'Accept-Ranges': 'bytes', - ...(response.headers.get('content-length') ? { 'Content-Length': response.headers.get('content-length')! } : {}) - } - }); -}; diff --git a/src/routes/api/books/[id]/highlights/+server.ts b/src/routes/api/books/[id]/highlights/+server.ts deleted file mode 100644 index df2c8685..00000000 --- a/src/routes/api/books/[id]/highlights/+server.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { json, error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getDb, schema } from '$lib/db'; -import { eq, and, desc } from 'drizzle-orm'; -import crypto from 'crypto'; - -export const GET: RequestHandler = async ({ params, locals }) => { - if (!locals.user) throw error(401); - const db = getDb(); - const highlights = db.select().from(schema.bookHighlights) - .where(and( - eq(schema.bookHighlights.userId, locals.user.id), - eq(schema.bookHighlights.bookId, params.id) - )) - .orderBy(desc(schema.bookHighlights.createdAt)) - .all(); - return json({ highlights }); -}; - -export const POST: RequestHandler = async ({ params, locals, request }) => { - if (!locals.user) throw error(401); - const { cfi, text, note, color, chapter, serviceId } = await request.json(); - if (!cfi || !text) throw error(400, 'cfi and text required'); - if (!serviceId) throw error(400, 'serviceId required'); - - const highlight = { - id: crypto.randomUUID(), - userId: locals.user.id, - bookId: params.id, - serviceId, - cfi, - text, - note: note ?? null, - color: color ?? 'yellow', - chapter: chapter ?? null, - createdAt: Date.now() - }; - - const db = getDb(); - db.insert(schema.bookHighlights).values(highlight).run(); - return json(highlight, { status: 201 }); -}; diff --git a/src/routes/api/books/[id]/highlights/[highlightId]/+server.ts b/src/routes/api/books/[id]/highlights/[highlightId]/+server.ts deleted file mode 100644 index 23385e17..00000000 --- a/src/routes/api/books/[id]/highlights/[highlightId]/+server.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { json, error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getDb, schema } from '$lib/db'; -import { eq, and } from 'drizzle-orm'; - -export const DELETE: RequestHandler = async ({ params, locals }) => { - if (!locals.user) throw error(401); - const db = getDb(); - const existing = db.select().from(schema.bookHighlights) - .where(and( - eq(schema.bookHighlights.id, params.highlightId), - eq(schema.bookHighlights.userId, locals.user.id) - )) - .get(); - if (!existing) throw error(404); - - db.delete(schema.bookHighlights) - .where(eq(schema.bookHighlights.id, params.highlightId)) - .run(); - - return json({ ok: true }); -}; diff --git a/src/routes/api/books/[id]/notes/+server.ts b/src/routes/api/books/[id]/notes/+server.ts deleted file mode 100644 index 6e32ee4e..00000000 --- a/src/routes/api/books/[id]/notes/+server.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { json, error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getDb, schema } from '$lib/db'; -import { eq, and, desc } from 'drizzle-orm'; -import crypto from 'crypto'; - -export const GET: RequestHandler = async ({ params, locals }) => { - if (!locals.user) throw error(401); - const db = getDb(); - const notes = db.select().from(schema.bookNotes) - .where(and( - eq(schema.bookNotes.userId, locals.user.id), - eq(schema.bookNotes.bookId, params.id) - )) - .orderBy(desc(schema.bookNotes.createdAt)) - .all(); - return json({ notes }); -}; - -export const POST: RequestHandler = async ({ params, locals, request }) => { - if (!locals.user) throw error(401); - const { content, serviceId } = await request.json(); - if (!content) throw error(400, 'content required'); - if (!serviceId) throw error(400, 'serviceId required'); - - const now = Date.now(); - const note = { - id: crypto.randomUUID(), - userId: locals.user.id, - bookId: params.id, - serviceId, - content, - createdAt: now, - updatedAt: now - }; - - const db = getDb(); - db.insert(schema.bookNotes).values(note).run(); - return json(note, { status: 201 }); -}; diff --git a/src/routes/api/books/[id]/notes/[noteId]/+server.ts b/src/routes/api/books/[id]/notes/[noteId]/+server.ts deleted file mode 100644 index 6d9ffde9..00000000 --- a/src/routes/api/books/[id]/notes/[noteId]/+server.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { json, error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getDb, schema } from '$lib/db'; -import { eq, and } from 'drizzle-orm'; - -export const PUT: RequestHandler = async ({ params, locals, request }) => { - if (!locals.user) throw error(401); - const { content } = await request.json(); - if (!content) throw error(400, 'content required'); - - const db = getDb(); - const existing = db.select().from(schema.bookNotes) - .where(and( - eq(schema.bookNotes.id, params.noteId), - eq(schema.bookNotes.userId, locals.user.id) - )) - .get(); - if (!existing) throw error(404); - - db.update(schema.bookNotes) - .set({ content, updatedAt: Date.now() }) - .where(eq(schema.bookNotes.id, params.noteId)) - .run(); - - return json({ ...existing, content, updatedAt: Date.now() }); -}; - -export const DELETE: RequestHandler = async ({ params, locals }) => { - if (!locals.user) throw error(401); - const db = getDb(); - const existing = db.select().from(schema.bookNotes) - .where(and( - eq(schema.bookNotes.id, params.noteId), - eq(schema.bookNotes.userId, locals.user.id) - )) - .get(); - if (!existing) throw error(404); - - db.delete(schema.bookNotes) - .where(eq(schema.bookNotes.id, params.noteId)) - .run(); - - return json({ ok: true }); -}; diff --git a/src/routes/api/books/[id]/progress/+server.ts b/src/routes/api/books/[id]/progress/+server.ts deleted file mode 100644 index 55446d75..00000000 --- a/src/routes/api/books/[id]/progress/+server.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { json, error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getConfigsForMediaType } from '$lib/server/services'; -import { - getLatestSession, - upsertPlaySession -} from '$lib/server/play-sessions'; - -/** - * Book reader progress endpoint. - * - * CANONICAL: writes ONLY to `play_sessions` — the single source of truth for - * reading progress, resume position, duration, and completion. The legacy - * `activity` table was dropped 2026-04-17 (migration 0008), and the - * secondary `book_reading_sessions` detail table was dropped 2026-04-17 - * (migration 0012) once every field it carried was either derivable from - * `play_sessions` or never populated by the real writer. Reader-side - * annotations (notes, highlights, bookmarks) live in their own tables — - * they never shared truth with this endpoint. - */ - -export const GET: RequestHandler = async ({ params, locals, url }) => { - if (!locals.user) throw error(401); - const serviceId = - url.searchParams.get('serviceId') ?? getConfigsForMediaType('book')[0]?.id ?? ''; - const row = getLatestSession(locals.user.id, serviceId, params.id); - if (!row) return json(null); - return json({ - id: row.id, - userId: row.user_id, - mediaId: row.media_id, - serviceId: row.service_id, - progress: row.progress, - position: row.position, - completed: !!row.completed, - startedAt: row.started_at, - endedAt: row.ended_at, - updatedAt: row.updated_at - }); -}; - -export const PUT: RequestHandler = async ({ params, locals, request }) => { - if (!locals.user) throw error(401); - const { progress, cfi, page, serviceId, ended } = await request.json(); - if (typeof progress !== 'number') throw error(400, 'progress required'); - - const svcId = serviceId ?? getConfigsForMediaType('book')[0]?.id ?? ''; - const userId = locals.user.id; - const bookId = params.id; - const position = cfi ?? (page != null ? String(page) : null); - - upsertPlaySession({ - userId, - serviceId: svcId, - serviceType: 'calibre', - mediaId: bookId, - mediaType: 'book', - sessionKey: `reader:${svcId}:${bookId}:${userId}`, - progress, - position, - source: 'reader', - stopped: ended === true, - completed: progress >= 1 - }); - - return json({ ok: true }); -}; diff --git a/src/routes/api/books/[id]/read/+server.ts b/src/routes/api/books/[id]/read/+server.ts deleted file mode 100644 index 8acb2288..00000000 --- a/src/routes/api/books/[id]/read/+server.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getConfigsForMediaType } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { registry } from '$lib/adapters/registry'; - -export const GET: RequestHandler = async ({ params, locals }) => { - if (!locals.user) throw error(401); - const config = getConfigsForMediaType('book')[0]; - if (!config) throw error(404, 'No Calibre service configured'); - - const adapter = registry.get(config.type); - const userCred = getUserCredentialForService(locals.user.id, config.id) ?? undefined; - const response = await adapter?.downloadContent?.(config, params.id, 'epub', userCred); - if (!response) throw error(500, 'Download not supported'); - - return new Response(response.body, { - headers: { - 'Content-Type': 'application/epub+zip', - 'Cache-Control': 'private, max-age=86400' - } - }); -}; diff --git a/src/routes/api/books/[id]/toggle-read/+server.ts b/src/routes/api/books/[id]/toggle-read/+server.ts deleted file mode 100644 index 9bd46599..00000000 --- a/src/routes/api/books/[id]/toggle-read/+server.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { json, error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getConfigsForMediaType } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { registry } from '$lib/adapters/registry'; - -export const POST: RequestHandler = async ({ params, locals }) => { - if (!locals.user) throw error(401); - const config = getConfigsForMediaType('book')[0]; - if (!config) throw error(404, 'No Calibre service configured'); - - const adapter = registry.get(config.type); - const userCred = getUserCredentialForService(locals.user.id, config.id) ?? undefined; - await adapter?.setItemStatus?.(config, params.id, { read: true }, userCred); - return json({ ok: true }); -}; diff --git a/src/routes/api/books/authors/+server.ts b/src/routes/api/books/authors/+server.ts deleted file mode 100644 index 0cb152f5..00000000 --- a/src/routes/api/books/authors/+server.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { json, error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getConfigsForMediaType } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { registry } from '$lib/adapters/registry'; - -export const GET: RequestHandler = async ({ locals }) => { - if (!locals.user) throw error(401); - const config = getConfigsForMediaType('book')[0]; - if (!config) throw error(404, 'No Calibre service configured'); - - const adapter = registry.get(config.type); - const userCred = getUserCredentialForService(locals.user.id, config.id) ?? undefined; - const authors = await adapter?.getServiceData?.(config, 'authors', {}, userCred) ?? []; - return json({ authors }); -}; diff --git a/src/routes/api/books/categories/+server.ts b/src/routes/api/books/categories/+server.ts deleted file mode 100644 index 2510ce17..00000000 --- a/src/routes/api/books/categories/+server.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { json, error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getConfigsForMediaType } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { registry } from '$lib/adapters/registry'; - -export const GET: RequestHandler = async ({ locals }) => { - if (!locals.user) throw error(401); - const config = getConfigsForMediaType('book')[0]; - if (!config) throw error(404, 'No Calibre service configured'); - - const adapter = registry.get(config.type); - const userCred = getUserCredentialForService(locals.user.id, config.id) ?? undefined; - const categories = await adapter?.getServiceData?.(config, 'categories', {}, userCred) ?? []; - return json({ categories }); -}; diff --git a/src/routes/api/books/goals/+server.ts b/src/routes/api/books/goals/+server.ts deleted file mode 100644 index 9f1fc9c0..00000000 --- a/src/routes/api/books/goals/+server.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { json, error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getDb, schema } from '$lib/db'; -import { eq, and } from 'drizzle-orm'; -import crypto from 'crypto'; - -export const GET: RequestHandler = async ({ locals }) => { - if (!locals.user) throw error(401); - const db = getDb(); - const goals = db.select().from(schema.readingGoals) - .where(eq(schema.readingGoals.userId, locals.user.id)) - .all(); - return json({ goals }); -}; - -export const POST: RequestHandler = async ({ locals, request }) => { - if (!locals.user) throw error(401); - const { targetBooks, targetPages, period, year, month } = await request.json(); - if (!period || !year) throw error(400, 'period and year required'); - - const goal = { - id: crypto.randomUUID(), - userId: locals.user.id, - targetBooks: targetBooks ?? null, - targetPages: targetPages ?? null, - period, - year, - month: month ?? null - }; - - const db = getDb(); - db.insert(schema.readingGoals).values(goal).run(); - return json(goal, { status: 201 }); -}; - -export const PUT: RequestHandler = async ({ locals, request }) => { - if (!locals.user) throw error(401); - const { id, targetBooks, targetPages } = await request.json(); - if (!id) throw error(400, 'id required'); - - const db = getDb(); - const existing = db.select().from(schema.readingGoals) - .where(and( - eq(schema.readingGoals.id, id), - eq(schema.readingGoals.userId, locals.user.id) - )) - .get(); - if (!existing) throw error(404); - - const updates: Record = {}; - if (targetBooks !== undefined) updates.targetBooks = targetBooks; - if (targetPages !== undefined) updates.targetPages = targetPages; - - db.update(schema.readingGoals) - .set(updates) - .where(eq(schema.readingGoals.id, id)) - .run(); - - return json({ ...existing, ...updates }); -}; diff --git a/src/routes/api/books/series/+server.ts b/src/routes/api/books/series/+server.ts deleted file mode 100644 index 28a02dda..00000000 --- a/src/routes/api/books/series/+server.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { json, error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getConfigsForMediaType } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { registry } from '$lib/adapters/registry'; - -export const GET: RequestHandler = async ({ locals }) => { - if (!locals.user) throw error(401); - const config = getConfigsForMediaType('book')[0]; - if (!config) throw error(404, 'No Calibre service configured'); - - const adapter = registry.get(config.type); - const userCred = getUserCredentialForService(locals.user.id, config.id) ?? undefined; - const series = await adapter?.getServiceData?.(config, 'series', {}, userCred) ?? []; - return json({ series }); -}; diff --git a/src/routes/api/books/stats/+server.ts b/src/routes/api/books/stats/+server.ts deleted file mode 100644 index d71bcbf8..00000000 --- a/src/routes/api/books/stats/+server.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { json, error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getDb, schema } from '$lib/db'; -import { eq, and, sql, isNotNull } from 'drizzle-orm'; - -export const GET: RequestHandler = async ({ locals }) => { - if (!locals.user) throw error(401); - const db = getDb(); - - // Total completed books - const completedBooks = db.select({ count: sql`count(distinct ${schema.playSessions.mediaId})` }) - .from(schema.playSessions) - .where(and( - eq(schema.playSessions.userId, locals.user.id), - eq(schema.playSessions.mediaType, 'book'), - eq(schema.playSessions.completed, 1) - )) - .get()?.count ?? 0; - - // Total reading time from closed book sessions (play_sessions is canonical). - // `pages_read` is not tracked in the unified model, so we report 0 for pages - // rather than carrying a secondary truth. - const sessionStats = db.select({ - totalSeconds: sql`coalesce(sum(${schema.playSessions.durationMs}) / 1000, 0)` - }) - .from(schema.playSessions) - .where(and( - eq(schema.playSessions.userId, locals.user.id), - eq(schema.playSessions.mediaType, 'book'), - isNotNull(schema.playSessions.endedAt) - )) - .get(); - - // Monthly breakdown (last 12 months) — groups on started_at. - const monthlyBreakdown = db.select({ - month: sql`strftime('%Y-%m', ${schema.playSessions.startedAt} / 1000, 'unixepoch')`, - pages: sql`0`, - sessions: sql`count(*)`, - readingTime: sql`coalesce(sum(${schema.playSessions.durationMs}) / 1000, 0)` - }) - .from(schema.playSessions) - .where(and( - eq(schema.playSessions.userId, locals.user.id), - eq(schema.playSessions.mediaType, 'book'), - isNotNull(schema.playSessions.endedAt) - )) - .groupBy(sql`strftime('%Y-%m', ${schema.playSessions.startedAt} / 1000, 'unixepoch')`) - .orderBy(sql`strftime('%Y-%m', ${schema.playSessions.startedAt} / 1000, 'unixepoch') desc`) - .limit(12) - .all(); - - // Reading streak: consecutive days with closed sessions. - const recentDays = db.select({ - day: sql`distinct strftime('%Y-%m-%d', ${schema.playSessions.startedAt} / 1000, 'unixepoch')` - }) - .from(schema.playSessions) - .where(and( - eq(schema.playSessions.userId, locals.user.id), - eq(schema.playSessions.mediaType, 'book'), - isNotNull(schema.playSessions.endedAt) - )) - .orderBy(sql`strftime('%Y-%m-%d', ${schema.playSessions.startedAt} / 1000, 'unixepoch') desc`) - .limit(365) - .all(); - - let streak = 0; - const today = new Date(); - for (let i = 0; i < recentDays.length; i++) { - const expected = new Date(today); - expected.setDate(expected.getDate() - i); - const expectedStr = expected.toISOString().slice(0, 10); - if (recentDays[i].day === expectedStr) { - streak++; - } else { - break; - } - } - - return json({ - totalBooksRead: completedBooks, - totalPages: 0, - totalReadingTimeSeconds: sessionStats?.totalSeconds ?? 0, - currentStreak: streak, - monthlyBreakdown - }); -}; diff --git a/src/routes/api/calendar/+server.ts b/src/routes/api/calendar/+server.ts deleted file mode 100644 index df212d34..00000000 --- a/src/routes/api/calendar/+server.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getEnabledConfigs } from '$lib/server/services'; -import { registry } from '$lib/adapters/registry'; -import { withCache } from '$lib/server/cache'; -import { requireActiveUser } from '$lib/server/session-guard'; -import type { CalendarItem } from '$lib/adapters/types'; - -export const GET: RequestHandler = async (event) => { - const user = requireActiveUser(event); - const { url } = event; - const days = Math.min(parseInt(url.searchParams.get('days') ?? '7', 10), 90); - const typesParam = url.searchParams.get('types'); - const allowedTypes = typesParam ? new Set(typesParam.split(',')) : null; - - const now = new Date(); - const start = now.toISOString(); - const end = new Date(now.getTime() + days * 86_400_000).toISOString(); - - // Per-user cache key. Content is sourced from admin-configured Radarr/Sonarr - // instances so it's currently identical for all users, but keying by user - // avoids a future repeat of issue #4 if per-user credentials ever diverge. - const items = await withCache( - `calendar:${user.id}:${days}:${typesParam ?? 'all'}`, - 300_000, - async () => { - const configs = getEnabledConfigs(); - const results: CalendarItem[] = []; - - await Promise.allSettled( - configs.map(async (config) => { - const adapter = registry.get(config.type); - if (!adapter?.getCalendar) return; - const calItems = await adapter.getCalendar(config, start, end); - for (const item of calItems) { - if (!allowedTypes || allowedTypes.has(item.mediaType)) { - results.push(item); - } - } - }) - ); - - return results.sort((a, b) => a.releaseDate.localeCompare(b.releaseDate)); - } - ); - - return json(items); -}; diff --git a/src/routes/api/collections/[id]/activity/+server.ts b/src/routes/api/collections/[id]/activity/+server.ts deleted file mode 100644 index 5d2195f7..00000000 --- a/src/routes/api/collections/[id]/activity/+server.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { getCollectionActivity } from '$lib/server/collection-activity'; -import { getCollection } from '$lib/server/social'; -import type { RequestHandler } from './$types'; - -export const GET: RequestHandler = async ({ params, url, locals }) => { - if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 }); - - // Verify access - const collection = getCollection(params.id, locals.user.id); - if (!collection) return json({ error: 'Not found' }, { status: 404 }); - - const limit = parseInt(url.searchParams.get('limit') ?? '20'); - const offset = parseInt(url.searchParams.get('offset') ?? '0'); - - const activity = getCollectionActivity(params.id, { limit, offset }); - return json({ activity }); -}; diff --git a/src/routes/api/collections/[id]/items/+server.ts b/src/routes/api/collections/[id]/items/+server.ts deleted file mode 100644 index 8776fa43..00000000 --- a/src/routes/api/collections/[id]/items/+server.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { addCollectionItem, reorderCollectionItems } from '$lib/server/social'; -import type { RequestHandler } from './$types'; - -// POST /api/collections/:id/items — add media to collection -// Body: { mediaId, serviceId, mediaType, mediaTitle, mediaPoster? } -export const POST: RequestHandler = async ({ params, request, locals }) => { - if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 }); - - const body = await request.json(); - const { mediaId, serviceId, mediaType, mediaTitle, mediaPoster } = body; - - if (!mediaId || !serviceId || !mediaType || !mediaTitle) { - return json({ error: 'Missing required fields' }, { status: 400 }); - } - - const itemId = addCollectionItem(params.id, locals.user.id, { - mediaId, serviceId, mediaType, mediaTitle, mediaPoster - }); - - if (!itemId) return json({ error: 'No permission or collection not found' }, { status: 403 }); - - return json({ id: itemId }); -}; - -// PATCH /api/collections/:id/items — reorder items -export const PATCH: RequestHandler = async ({ params, request, locals }) => { - if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 }); - const body = await request.json(); - const { orderedIds } = body; - if (!Array.isArray(orderedIds)) return json({ error: 'Missing orderedIds' }, { status: 400 }); - - const ok = reorderCollectionItems(params.id, locals.user.id, orderedIds); - if (!ok) return json({ error: 'No permission' }, { status: 403 }); - return json({ ok: true }); -}; diff --git a/src/routes/api/collections/[id]/items/[itemId]/+server.ts b/src/routes/api/collections/[id]/items/[itemId]/+server.ts deleted file mode 100644 index df32d33d..00000000 --- a/src/routes/api/collections/[id]/items/[itemId]/+server.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { removeCollectionItem } from '$lib/server/social'; -import type { RequestHandler } from './$types'; - -// DELETE /api/collections/:id/items/:itemId — remove media from collection -export const DELETE: RequestHandler = async ({ params, locals }) => { - if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 }); - - const ok = removeCollectionItem(params.id, params.itemId, locals.user.id); - if (!ok) return json({ error: 'Not found or no permission' }, { status: 403 }); - - return json({ ok: true }); -}; diff --git a/src/routes/api/collections/[id]/members/+server.ts b/src/routes/api/collections/[id]/members/+server.ts deleted file mode 100644 index e5a8c14c..00000000 --- a/src/routes/api/collections/[id]/members/+server.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { addCollectionMember } from '$lib/server/social'; -import type { RequestHandler } from './$types'; - -// POST /api/collections/:id/members — add collaborator -// Body: { userId, role?: 'editor' | 'viewer' } -export const POST: RequestHandler = async ({ params, request, locals }) => { - if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 }); - - const body = await request.json(); - const { userId, role } = body; - - if (!userId) return json({ error: 'Missing userId' }, { status: 400 }); - - const validRoles = ['editor', 'viewer']; - if (role && !validRoles.includes(role)) { - return json({ error: 'Invalid role' }, { status: 400 }); - } - - const ok = addCollectionMember(params.id, locals.user.id, userId, role ?? 'editor'); - if (!ok) return json({ error: 'Not owner or already a member' }, { status: 403 }); - - return json({ ok: true }); -}; diff --git a/src/routes/api/collections/[id]/members/[userId]/+server.ts b/src/routes/api/collections/[id]/members/[userId]/+server.ts deleted file mode 100644 index 734ff648..00000000 --- a/src/routes/api/collections/[id]/members/[userId]/+server.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { removeCollectionMember } from '$lib/server/social'; -import type { RequestHandler } from './$types'; - -// DELETE /api/collections/:id/members/:userId — remove collaborator -export const DELETE: RequestHandler = async ({ params, locals }) => { - if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 }); - - const ok = removeCollectionMember(params.id, locals.user.id, params.userId); - if (!ok) return json({ error: 'Not owner or cannot remove' }, { status: 403 }); - - return json({ ok: true }); -}; diff --git a/src/routes/api/dashboard/+server.ts b/src/routes/api/dashboard/+server.ts deleted file mode 100644 index e83c3d12..00000000 --- a/src/routes/api/dashboard/+server.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { getDashboard } from '$lib/server/services'; -import type { RequestHandler } from './$types'; - -export const GET: RequestHandler = async ({ locals }) => { - try { - const rows = await getDashboard(locals.user?.id); - return json(rows); - } catch (e) { - console.error('[API] dashboard error', e); - return json({ error: 'Failed to load dashboard' }, { status: 500 }); - } -}; diff --git a/src/routes/api/discover/+server.ts b/src/routes/api/discover/+server.ts deleted file mode 100644 index dc2dde69..00000000 --- a/src/routes/api/discover/+server.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { registry } from '$lib/adapters/registry'; -import { getEnabledConfigs } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { withCache } from '$lib/server/cache'; -import type { UnifiedMedia } from '$lib/adapters/types'; -import type { RequestHandler } from './$types'; - -// GET /api/discover?page=1&category=trending|movies|tv|upcoming-movies|upcoming-tv|popular-movies|popular-tv|genre-movie|genre-tv|network&genreId=28&networkId=213 -export const GET: RequestHandler = async ({ url, locals }) => { - if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 }); - - const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10)); - const category = url.searchParams.get('category') ?? 'trending'; - const genreId = url.searchParams.get('genreId') ?? undefined; - const networkId = url.searchParams.get('networkId') ?? undefined; - const userId = locals.user.id; - - const cacheKey = `discover:${category}:${genreId ?? ''}:${networkId ?? ''}:${page}`; - - const result = await withCache(cacheKey, 900_000, async () => { - const configs = getEnabledConfigs().filter((c) => { - const adapter = registry.get(c.type); - return !!adapter?.discover; - }); - const allItems: UnifiedMedia[] = []; - let hasMore = false; - - await Promise.allSettled( - configs.map(async (config) => { - const adapter = registry.get(config.type); - if (!adapter?.discover) return; - const cred = getUserCredentialForService(userId, config.id) ?? undefined; - const res = await adapter.discover(config, { page, category, genreId, networkId }, cred); - allItems.push(...(res?.items ?? [])); - if (res?.hasMore) hasMore = true; - }) - ); - - // Deduplicate by sourceId - const seen = new Set(); - const items = allItems.filter((i) => { - if (seen.has(i.sourceId)) return false; - seen.add(i.sourceId); - return true; - }); - - return { items, hasMore, page }; - }); - - return json(result); -}; diff --git a/src/routes/api/discover/genres/+server.ts b/src/routes/api/discover/genres/+server.ts deleted file mode 100644 index 6b29678e..00000000 --- a/src/routes/api/discover/genres/+server.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { registry } from '$lib/adapters/registry'; -import { getEnabledConfigs } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { withCache } from '$lib/server/cache'; -import type { RequestHandler } from './$types'; - -// GET /api/discover/genres?type=movie|tv -export const GET: RequestHandler = async ({ url, locals }) => { - if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 }); - - const type = url.searchParams.get('type') ?? 'movie'; - - const genres = await withCache(`genres:${type}`, 3_600_000, async () => { - const configs = getEnabledConfigs().filter((c) => { - const adapter = registry.get(c.type); - return !!adapter?.getServiceData; - }); - - for (const config of configs) { - const adapter = registry.get(config.type); - if (!adapter?.getServiceData) continue; - const cred = getUserCredentialForService(locals.user!.id, config.id) ?? undefined; - try { - const data = await adapter.getServiceData(config, `genres-${type}`, {}, cred); - if (data) return data; - } catch { continue; } - } - return []; - }); - - return json(genres); -}; diff --git a/src/routes/api/franchise/+server.ts b/src/routes/api/franchise/+server.ts deleted file mode 100644 index 609032ba..00000000 --- a/src/routes/api/franchise/+server.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { getFranchiseData } from '$lib/server/franchise'; - -export const GET: RequestHandler = async ({ url, locals }) => { - if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 }); - - const name = url.searchParams.get('name'); - if (!name) return json({ error: 'Missing name param' }, { status: 400 }); - - const data = await getFranchiseData(name, locals.user.id); - return json(data); -}; diff --git a/src/routes/api/friends/+server.ts b/src/routes/api/friends/+server.ts deleted file mode 100644 index 67463d6c..00000000 --- a/src/routes/api/friends/+server.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { getFriends, getBlockedUserIds, getBlockedByUserIds } from '$lib/server/social'; -import { getAllUsers } from '$lib/server/auth'; -import type { RequestHandler } from './$types'; - -// GET /api/friends — list all friends with presence -export const GET: RequestHandler = async ({ locals }) => { - if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 }); - const friends = getFriends(locals.user.id); - return json({ friends }); -}; - -// POST /api/friends — search users to add (not a friend action itself) -// Body: { query: string } — search by username/displayName -export const POST: RequestHandler = async ({ request, locals }) => { - if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 }); - - const { query } = await request.json(); - if (!query || typeof query !== 'string') return json({ error: 'Missing query' }, { status: 400 }); - - const all = getAllUsers(); - const q = query.toLowerCase(); - - // Exclude blocked users (in both directions) - const blockedIds = new Set([ - ...getBlockedUserIds(locals.user.id), - ...getBlockedByUserIds(locals.user.id) - ]); - - const results = all - .filter((u) => u.id !== locals.user!.id && !blockedIds.has(u.id) && (u.username.toLowerCase().includes(q) || u.displayName.toLowerCase().includes(q))) - .map((u) => ({ id: u.id, username: u.username, displayName: u.displayName })) - .slice(0, 20); - - return json({ users: results }); -}; diff --git a/src/routes/api/friends/[id]/+server.ts b/src/routes/api/friends/[id]/+server.ts deleted file mode 100644 index 1fb989fa..00000000 --- a/src/routes/api/friends/[id]/+server.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { getFriends, removeFriend } from '$lib/server/social'; -import type { RequestHandler } from './$types'; - -// GET /api/friends/:id — single friend profile -export const GET: RequestHandler = async ({ params, locals }) => { - if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 }); - - const friends = getFriends(locals.user.id); - const friend = friends.find((f) => f.userId === params.id); - if (!friend) return json({ error: 'Not found' }, { status: 404 }); - - return json({ friend }); -}; - -// DELETE /api/friends/:id — remove friend -export const DELETE: RequestHandler = async ({ params, locals }) => { - if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 }); - - const ok = removeFriend(locals.user.id, params.id); - if (!ok) return json({ error: 'Not found' }, { status: 404 }); - - return json({ ok: true }); -}; diff --git a/src/routes/api/friends/[id]/activity/+server.ts b/src/routes/api/friends/[id]/activity/+server.ts deleted file mode 100644 index de7845f3..00000000 --- a/src/routes/api/friends/[id]/activity/+server.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { areFriends, getFriendActivity } from '$lib/server/social'; -import type { RequestHandler } from './$types'; - -// GET /api/friends/:id/activity — activity history for one friend -export const GET: RequestHandler = async ({ params, url, locals }) => { - if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 }); - if (!areFriends(locals.user.id, params.id)) return json({ error: 'Not friends' }, { status: 403 }); - - const limit = parseInt(url.searchParams.get('limit') ?? '50'); - const offset = parseInt(url.searchParams.get('offset') ?? '0'); - - const { getRawDb } = await import('$lib/db'); - const raw = getRawDb(); - const events = raw.prepare( - `SELECT * FROM play_sessions WHERE user_id = ? ORDER BY started_at DESC LIMIT ? OFFSET ?` - ).all(params.id, limit, offset) as any[]; - - return json({ activity: events }); -}; diff --git a/src/routes/api/friends/[id]/block/+server.ts b/src/routes/api/friends/[id]/block/+server.ts deleted file mode 100644 index 831d749a..00000000 --- a/src/routes/api/friends/[id]/block/+server.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { blockUser, unblockUser } from '$lib/server/social'; -import type { RequestHandler } from './$types'; - -// POST /api/friends/:id/block — block a user -export const POST: RequestHandler = async ({ params, locals }) => { - if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 }); - - const ok = blockUser(locals.user.id, params.id); - if (!ok) return json({ error: 'Cannot block this user' }, { status: 400 }); - - return json({ ok: true }); -}; - -// DELETE /api/friends/:id/block — unblock a user -export const DELETE: RequestHandler = async ({ params, locals }) => { - if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 }); - - const ok = unblockUser(locals.user.id, params.id); - if (!ok) return json({ error: 'Not blocked' }, { status: 404 }); - - return json({ ok: true }); -}; diff --git a/src/routes/api/friends/online/+server.ts b/src/routes/api/friends/online/+server.ts deleted file mode 100644 index 2cbcaf1e..00000000 --- a/src/routes/api/friends/online/+server.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { getOnlineFriends } from '$lib/server/social'; -import type { RequestHandler } from './$types'; - -// GET /api/friends/online — online/away friends only -export const GET: RequestHandler = async ({ locals }) => { - if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 }); - const friends = getOnlineFriends(locals.user.id); - return json({ friends }); -}; diff --git a/src/routes/api/friends/requests/+server.ts b/src/routes/api/friends/requests/+server.ts deleted file mode 100644 index 641a8f9a..00000000 --- a/src/routes/api/friends/requests/+server.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { getPendingRequests, sendFriendRequest } from '$lib/server/social'; -import { broadcastToUser } from '$lib/server/ws'; -import { createNotification } from '$lib/server/notifications'; -import type { RequestHandler } from './$types'; - -// GET /api/friends/requests — list pending friend requests -export const GET: RequestHandler = async ({ locals }) => { - if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 }); - const requests = getPendingRequests(locals.user.id); - return json({ requests }); -}; - -// POST /api/friends/requests — send a friend request -// Body: { userId: string } -export const POST: RequestHandler = async ({ request, locals }) => { - if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 }); - - const { userId } = await request.json(); - if (!userId) return json({ error: 'Missing userId' }, { status: 400 }); - - const result = sendFriendRequest(locals.user.id, userId); - if ('error' in result) return json({ error: result.error }, { status: 400 }); - - // Notify recipient via WS - broadcastToUser(userId, { - type: 'presence:notification', - data: { - notificationType: 'friend_request', - fromUserId: locals.user.id, - fromUsername: locals.user.username, - fromDisplayName: locals.user.displayName, - requestId: result.id - } - }); - - // Persist notification - createNotification({ - userId, - type: 'friend_request', - title: `${locals.user.displayName} sent you a friend request`, - icon: 'user-plus', - href: '/friends', - actorId: locals.user.id, - metadata: { requestId: result.id } - }); - - return json({ id: result.id }); -}; diff --git a/src/routes/api/friends/requests/[id]/+server.ts b/src/routes/api/friends/requests/[id]/+server.ts deleted file mode 100644 index 95b13095..00000000 --- a/src/routes/api/friends/requests/[id]/+server.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { acceptFriendRequest, declineFriendRequest } from '$lib/server/social'; -import { broadcastToUser } from '$lib/server/ws'; -import { createNotification } from '$lib/server/notifications'; -import type { RequestHandler } from './$types'; - -// PATCH /api/friends/requests/:id — accept or decline a friend request. -// Body: { action: 'accept' | 'decline' | 'reject' } -// PUT remains wired to the same handler for back-compat with any external -// tooling; the in-app UI sends PATCH. -const handle: RequestHandler = async ({ params, request, locals }) => { - if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 }); - - const body = (await request.json().catch(() => ({}))) as { action?: string }; - const rawAction = body.action; - // 'reject' is treated as an alias of 'decline' for UI compatibility. - const action = - rawAction === 'accept' ? 'accept' : rawAction === 'decline' || rawAction === 'reject' ? 'decline' : null; - if (!action) { - return json({ error: 'Invalid action' }, { status: 400 }); - } - - if (action === 'accept') { - const ok = acceptFriendRequest(params.id, locals.user.id); - if (!ok) return json({ error: 'Request not found or not yours' }, { status: 404 }); - - // Notify the sender that the request was accepted - // We need the sender ID from the friendship — look it up - const { getDb, schema } = await import('$lib/db'); - const { eq } = await import('drizzle-orm'); - const db = getDb(); - const row = db.select().from(schema.friendships).where(eq(schema.friendships.id, params.id)).get(); - if (row) { - broadcastToUser(row.userId, { - type: 'presence:notification', - data: { - notificationType: 'friend_accepted', - userId: locals.user.id, - username: locals.user.username, - displayName: locals.user.displayName - } - }); - - // Persist notification - createNotification({ - userId: row.userId, - type: 'friend_accept', - title: `${locals.user.displayName} accepted your friend request`, - icon: 'user-check', - href: '/friends', - actorId: locals.user.id - }); - } - - return json({ ok: true, action: 'accepted' }); - } else { - const ok = declineFriendRequest(params.id, locals.user.id); - if (!ok) return json({ error: 'Request not found or not yours' }, { status: 404 }); - return json({ ok: true, action: 'declined' }); - } -}; - -export const PATCH: RequestHandler = handle; -export const PUT: RequestHandler = handle; diff --git a/src/routes/api/games/[id]/download/+server.ts b/src/routes/api/games/[id]/download/+server.ts deleted file mode 100644 index 71907ee6..00000000 --- a/src/routes/api/games/[id]/download/+server.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { error } from '@sveltejs/kit'; -import { getServiceConfig } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import type { RequestHandler } from './$types'; - -export const GET: RequestHandler = async ({ params, url, locals }) => { - const userId = locals.user?.id; - if (!userId) throw error(401, 'Not authenticated'); - - const serviceId = url.searchParams.get('serviceId'); - if (!serviceId) throw error(400, 'serviceId required'); - - const config = getServiceConfig(serviceId); - if (!config || config.type !== 'romm') throw error(404, 'RomM service not found'); - - const userCred = getUserCredentialForService(userId, serviceId) ?? undefined; - const headers: Record = {}; - - if (userCred?.accessToken) { - headers['Authorization'] = `Bearer ${userCred.accessToken}`; - } else if (config.username && config.password) { - headers['Authorization'] = `Basic ${btoa(`${config.username}:${config.password}`)}`; - } else if (config.apiKey) { - headers['Authorization'] = `Bearer ${config.apiKey}`; - } - - const baseUrl = config.url.replace(/\/+$/, ''); - const romRes = await fetch(`${baseUrl}/api/roms/${params.id}/content`, { - headers, - signal: AbortSignal.timeout(120000) - }); - - if (!romRes.ok) { - throw error(romRes.status, `Failed to download ROM: ${romRes.statusText}`); - } - - const contentDisposition = romRes.headers.get('content-disposition') ?? `attachment; filename="rom-${params.id}"`; - const contentType = romRes.headers.get('content-type') ?? 'application/octet-stream'; - const contentLength = romRes.headers.get('content-length'); - - const responseHeaders: Record = { - 'Content-Type': contentType, - 'Content-Disposition': contentDisposition - }; - if (contentLength) responseHeaders['Content-Length'] = contentLength; - - return new Response(romRes.body, { headers: responseHeaders }); -}; diff --git a/src/routes/api/games/[id]/emulator/+server.ts b/src/routes/api/games/[id]/emulator/+server.ts deleted file mode 100644 index 71f455b0..00000000 --- a/src/routes/api/games/[id]/emulator/+server.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { error } from '@sveltejs/kit'; -import { getServiceConfig } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { registry } from '$lib/adapters/registry'; -import { isPlayableInBrowser, getEmulatorJSConfig } from '$lib/emulator/cores'; -import type { RequestHandler } from './$types'; - -/** - * Serves a self-contained HTML page with EmulatorJS. - * Loaded inside an iframe on the /play page to avoid SPA global conflicts. - * Communicates save/load events to the parent via postMessage. - */ -export const GET: RequestHandler = async ({ params, url, locals }) => { - if (!locals.user) throw error(401); - - const serviceId = url.searchParams.get('serviceId'); - if (!serviceId) throw error(400, 'serviceId required'); - - const config = getServiceConfig(serviceId); - if (!config || config.type !== 'romm') throw error(404); - - const adapter = registry.get('romm'); - if (!adapter?.getItem) throw error(501); - - const userCred = getUserCredentialForService(locals.user.id, serviceId) ?? undefined; - const item = await adapter.getItem(config, params.id, userCred); - if (!item) throw error(404, 'Game not found'); - - const platformSlug = item.metadata?.platformSlug as string | undefined; - if (!platformSlug || !isPlayableInBrowser(platformSlug)) { - throw error(400, 'Platform not supported for emulation'); - } - - const romUrl = `/api/games/${params.id}/rom?serviceId=${serviceId}`; - const ejsConfig = getEmulatorJSConfig(platformSlug, romUrl); - if (!ejsConfig) throw error(500); - - const html = ` - - - - - - - -
- - - - Books — Nexus - - -
- - {#if data.featuredBook && data.total >= 5} - - {/if} - - - {#if data.continueReading.length > 0} -
- -
- {/if} - - - {#if data.recentlyAdded.length > 0 && data.total >= 10} -
- -
- {/if} - - - {#if data.readingStats && (data.readingStats.booksThisYear > 0 || data.readingStats.pagesThisMonth > 0)} -
- -
- {/if} - - - {#if data.series.length > 0 || data.authors.length > 1} -
- -
- {/if} - - - {#if activeTab === 'series'} - -
- {#if data.series.length === 0} -

No series found in your library.

- {:else} -
- {#each data.series as series (series.name)} - - {/each} -
- {/if} -
- {:else if activeTab === 'authors'} - -
- {#if data.authors.length === 0} -

No authors found in your library.

- {:else} -
- {#each data.authors as author (author.name)} - - {/each} -
- {/if} -
- {:else} - -
- -
-
-

Books

-

- {data.total} book{data.total === 1 ? '' : 's'} - {#if data.category}· {data.category}{/if} - {#if data.author}· {data.author}{/if} -

-
- - - {#if data.categories.length > 1 && data.total >= 5} -
- All - {#each data.categories.slice(0, 12) as cat} - {cat} - {/each} -
- {/if} -
- - - {#if data.total > 1} -
- -
- Sort -
- {#each sortOptions as s} - {s.label} - {/each} -
-
- - -
- Status -
- {#each statusOptions as s} - {s.label} - {/each} -
-
- - - - - - {#if data.series.length > 0} - - {/if} - - -
- - - -
- - - -
- {/if} - - - {#if filtered.length === 0} - {@const isFailedLoad = data.hasBookService && data.serviceStatus === 'offline'} - {@const isEmptyLibrary = data.hasBookService && data.serviceStatus === 'online' && data.items.length === 0} -
-
- {#if isFailedLoad} - - - - - {:else} - - - - {/if} -
- {#if isFailedLoad} -

Couldn't load Calibre library

-

- Calibre-Web is configured but unreachable right now. Check that the service is running and your credentials are correct. -

- {#if data.serviceError} -

{data.serviceError}

- {/if} -
- Check connection - -
- {:else if isEmptyLibrary} -

Your library is empty

-

- Calibre-Web is connected, but there are no books yet. Add books through Calibre-Web's interface to see them here. -

- {:else if !data.hasBookService} -

No books found

-

Connect Calibre to see your book collection here.

- Connect a Service - {:else} -

No books found

-

Try adjusting your filters.

- {/if} -
- {:else if viewMode === 'shelf'} - - {:else if viewMode === 'list'} -
- {#each filtered as item (item.id)} - - {/each} -
- {:else} -
- {#if useVirtualScrolling} - {@const beforeHeight = Math.floor(visibleRange.start / cols) * CARD_HEIGHT} - {@const afterItems = displayItems.length - visibleRange.end} - {@const afterHeight = Math.ceil(Math.max(0, afterItems) / cols) * CARD_HEIGHT} - {#if beforeHeight > 0} -
- {/if} - {#each displayItems.slice(visibleRange.start, visibleRange.end) as entry (entry.type === 'series' ? `series:${entry.name}` : entry.item.id)} - {#if entry.type === 'series'} - { window.location.href = buildUrl({ tab: 'all', series: entry.name }); }} - /> - {:else} -
- - {#if entry.item.metadata?.formats} -
- {#each (entry.item.metadata.formats as { name: string }[]).slice(0, 3) as fmt (fmt.name)} - {fmt.name} - {/each} -
- {/if} -
- {/if} - {/each} - {#if afterHeight > 0} -
- {/if} - {:else} - {#each displayItems as entry (entry.type === 'series' ? `series:${entry.name}` : entry.item.id)} - {#if entry.type === 'series'} - { window.location.href = buildUrl({ tab: 'all', series: entry.name }); }} - /> - {:else} -
- - {#if entry.item.metadata?.formats} -
- {#each (entry.item.metadata.formats as { name: string }[]).slice(0, 3) as fmt (fmt.name)} - {fmt.name} - {/each} -
- {/if} -
- {/if} - {/each} - {/if} -
- {/if} -
- {/if} -
diff --git a/src/routes/books/notes/+page.server.ts b/src/routes/books/notes/+page.server.ts deleted file mode 100644 index 332241fe..00000000 --- a/src/routes/books/notes/+page.server.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { error } from '@sveltejs/kit'; -import { getDb, schema } from '$lib/db'; -import { eq, desc } from 'drizzle-orm'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ locals }) => { - if (!locals.user) throw error(401, 'Login required'); - const db = getDb(); - const userId = locals.user.id; - - const highlights = db.select().from(schema.bookHighlights) - .where(eq(schema.bookHighlights.userId, userId)) - .orderBy(desc(schema.bookHighlights.createdAt)) - .all(); - - const notes = db.select().from(schema.bookNotes) - .where(eq(schema.bookNotes.userId, userId)) - .orderBy(desc(schema.bookNotes.updatedAt)) - .all(); - - return { highlights, notes }; -}; diff --git a/src/routes/books/notes/+page.svelte b/src/routes/books/notes/+page.svelte deleted file mode 100644 index dae4a48a..00000000 --- a/src/routes/books/notes/+page.svelte +++ /dev/null @@ -1,237 +0,0 @@ - - - - Notes & Highlights - Nexus - - -
- -
-
-

Notes & Highlights

-

- {data.highlights.length} highlight{data.highlights.length !== 1 ? 's' : ''} - · {data.notes.length} note{data.notes.length !== 1 ? 's' : ''} -

-
-
- - -
-
- - -
- -
- Color -
- {#each colorOptions as c} - - {/each} -
-
- - -
- Sort -
- - -
-
- - - -
- - - {#if filteredHighlights.length === 0 && data.notes.length === 0} -
-
- - - -
-

No notes or highlights yet

-

Highlight text while reading to build your collection.

- Browse Books -
- {:else} - {#if filteredHighlights.length > 0} -
-

Highlights

-
- {#each filteredHighlights as h (h.id)} - {@const borderColor = colorMap[h.color ?? 'yellow'] ?? colorMap.yellow} - - -
-

{h.text}

-
- - - {#if h.note} -

{h.note}

- {/if} - - -
-
- {#if h.chapter} - {h.chapter} - {/if} - Book {h.bookId} -
- {relativeTime(h.createdAt)} -
-
- {/each} -
-
- {/if} - - - {#if notesByBook.size > 0} -
-

Notes

- {#each [...notesByBook] as [bookId, bookNotes] (bookId)} -
-

Book {bookId}

-
- {#each bookNotes as note (note.id)} -
-

{note.content}

-

{relativeTime(note.updatedAt)}

-
- {/each} -
-
- {/each} -
- {/if} - {/if} -
diff --git a/src/routes/books/read/[id]/+page.server.ts b/src/routes/books/read/[id]/+page.server.ts deleted file mode 100644 index 7d8a0d9e..00000000 --- a/src/routes/books/read/[id]/+page.server.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { error } from '@sveltejs/kit'; -import { getConfigsForMediaType } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { registry } from '$lib/adapters/registry'; -import { getDb, schema } from '$lib/db'; -import { eq, and, desc } from 'drizzle-orm'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ params, url, locals }) => { - if (!locals.user) throw error(401, 'Login required'); - const userId = locals.user.id; - - const serviceId = url.searchParams.get('service'); - const bookConfigs = getConfigsForMediaType('book'); - const calibreConfig = serviceId - ? bookConfigs.find(c => c.id === serviceId) - : bookConfigs[0]; - - if (!calibreConfig) throw error(404, 'No Calibre service configured'); - - const adapter = registry.get('calibre'); - if (!adapter?.getItem) throw error(500, 'Calibre adapter not available'); - const userCred = getUserCredentialForService(userId, calibreConfig.id) ?? undefined; - - const item = await adapter.getItem(calibreConfig, params.id, userCred); - if (!item) throw error(404, 'Book not found'); - - const db = getDb(); - - const sessionRow = db.select().from(schema.playSessions) - .where(and( - eq(schema.playSessions.userId, userId), - eq(schema.playSessions.mediaId, params.id), - eq(schema.playSessions.serviceId, calibreConfig.id), - eq(schema.playSessions.mediaType, 'book') - )) - .orderBy(desc(schema.playSessions.updatedAt)) - .get(); - - const bookmarks = db.select().from(schema.bookBookmarks) - .where(and( - eq(schema.bookBookmarks.userId, userId), - eq(schema.bookBookmarks.bookId, params.id), - eq(schema.bookBookmarks.serviceId, calibreConfig.id) - )) - .orderBy(desc(schema.bookBookmarks.createdAt)) - .all(); - - const highlights = db.select().from(schema.bookHighlights) - .where(and( - eq(schema.bookHighlights.userId, userId), - eq(schema.bookHighlights.bookId, params.id), - eq(schema.bookHighlights.serviceId, calibreConfig.id) - )) - .all(); - - // Resume position (EPUB CFI or PDF page) lives in play_sessions.position. - const savedPosition: string | undefined = sessionRow?.position ?? undefined; - - // Determine format to read — default to EPUB, allow ?format=pdf etc. - // Calibre adapter returns metadata.formats as CalibreFormat[] ({name, downloadUrl}), - // not string[]. Other adapters could legitimately produce strings, so accept both. - const requestedFormat = (url.searchParams.get('format') ?? 'epub').toLowerCase(); - const rawFormats = (item.metadata?.formats as Array | undefined) ?? []; - const availableFormats = rawFormats - .map(f => (typeof f === 'string' ? f : f?.name ?? '')) - .filter(Boolean) - .map(s => s.toLowerCase()); - const format = availableFormats.includes(requestedFormat) ? requestedFormat : 'epub'; - const bookUrl = format === 'epub' - ? `/api/books/${params.id}/read` - : `/api/books/${params.id}/download/${format}?view=true`; - - return { - book: item, - serviceId: calibreConfig.id, - bookUrl, - format, - availableFormats, - savedPosition, - progress: sessionRow?.progress ?? 0, - bookmarks, - highlights - }; -}; diff --git a/src/routes/books/read/[id]/+page.svelte b/src/routes/books/read/[id]/+page.svelte deleted file mode 100644 index 7067a64f..00000000 --- a/src/routes/books/read/[id]/+page.svelte +++ /dev/null @@ -1,69 +0,0 @@ - - - - Reading: {data.book.title} — Nexus - - -
- {#if loadError} -
{loadError}
- {:else if Reader} - {#if data.format === 'epub'} - - {:else} - - {/if} - {/if} -
diff --git a/src/routes/books/stats/+page.server.ts b/src/routes/books/stats/+page.server.ts deleted file mode 100644 index f69c66cd..00000000 --- a/src/routes/books/stats/+page.server.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { error } from '@sveltejs/kit'; -import { getDb, schema } from '$lib/db'; -import { eq, and, sql, isNotNull, desc } from 'drizzle-orm'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ locals }) => { - if (!locals.user) throw error(401, 'Login required'); - const db = getDb(); - const userId = locals.user.id; - - const booksFinished = db.select({ count: sql`count(distinct ${schema.playSessions.mediaId})` }) - .from(schema.playSessions) - .where(and( - eq(schema.playSessions.userId, userId), - eq(schema.playSessions.mediaType, 'book'), - eq(schema.playSessions.completed, 1) - )) - .get(); - - // Closed book reading sessions from play_sessions (the canonical progress - // store). An ended_at timestamp is what promotes a row from "currently - // reading" to "a completed reading session for stats purposes". - const sessions = db.select({ - id: schema.playSessions.id, - mediaId: schema.playSessions.mediaId, - startedAt: schema.playSessions.startedAt, - endedAt: schema.playSessions.endedAt, - durationMs: schema.playSessions.durationMs - }).from(schema.playSessions) - .where(and( - eq(schema.playSessions.userId, userId), - eq(schema.playSessions.mediaType, 'book'), - isNotNull(schema.playSessions.endedAt) - )) - .all(); - - const totalReadingSeconds = sessions.reduce( - (sum, s) => sum + Math.round((s.durationMs ?? 0) / 1000), - 0 - ); - - // Monthly breakdown (last 12 months) - const monthlyData: { month: string; pages: number; minutes: number }[] = []; - for (let i = 11; i >= 0; i--) { - const d = new Date(); - d.setMonth(d.getMonth() - i); - const mStart = new Date(d.getFullYear(), d.getMonth(), 1).getTime(); - const mEnd = new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime(); - - const mSessions = sessions.filter(s => s.startedAt >= mStart && s.startedAt < mEnd); - monthlyData.push({ - month: d.toLocaleDateString('en', { month: 'short', year: '2-digit' }), - // `pages` was never populated by the real writer (always null), so we - // now report 0 until a pages-read signal is actually captured. - pages: 0, - minutes: Math.round( - mSessions.reduce((s, r) => s + Math.round((r.durationMs ?? 0) / 1000), 0) / 60 - ) - }); - } - - // Reading streak - const sessionDays = new Set(sessions.map(s => { - const d = new Date(s.startedAt); - return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; - })); - let streak = 0; - const today = new Date(); - for (let i = 0; i < 365; i++) { - const d = new Date(today); - d.setDate(d.getDate() - i); - const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; - if (sessionDays.has(key)) streak++; - else if (i > 0) break; - } - - const goals = db.select().from(schema.readingGoals) - .where(eq(schema.readingGoals.userId, userId)) - .all(); - - const highlightCount = db.select({ count: sql`count(*)` }) - .from(schema.bookHighlights) - .where(eq(schema.bookHighlights.userId, userId)) - .get(); - - // Recent closed sessions, shaped for the stats page. - const recentSessions = [...sessions] - .sort((a, b) => b.startedAt - a.startedAt) - .slice(0, 20) - .map(s => ({ - id: s.id, - bookId: s.mediaId, - startedAt: s.startedAt, - durationSeconds: Math.round((s.durationMs ?? 0) / 1000) - })); - - return { - booksFinished: booksFinished?.count ?? 0, - totalReadingMinutes: Math.round(totalReadingSeconds / 60), - totalPagesRead: 0, - currentStreak: streak, - monthlyData, - goals, - highlightCount: highlightCount?.count ?? 0, - sessionCount: sessions.length, - recentSessions - }; -}; diff --git a/src/routes/books/stats/+page.svelte b/src/routes/books/stats/+page.svelte deleted file mode 100644 index 08e2b0e4..00000000 --- a/src/routes/books/stats/+page.svelte +++ /dev/null @@ -1,261 +0,0 @@ - - - - Reading Stats - Nexus - - -
-
-

Reading Stats

-

Your reading journey at a glance.

-
- - -
- -
-
- - - - Books Finished -
-

{formatNumber(data.booksFinished)}

-

all time

-
- - -
-
- - - - - Pages Read -
-

{formatNumber(data.totalPagesRead)}

-

all time

-
- - -
-
- - - - Streak -
-

{data.currentStreak}

-

day{data.currentStreak !== 1 ? 's' : ''}

-
- - -
-
- - - - Highlights -
-

{formatNumber(data.highlightCount)}

-

{data.sessionCount} session{data.sessionCount !== 1 ? 's' : ''}

-
-
- -
-
- -
-

Reading Activity

-

Minutes read per month

-
- {#each data.monthlyData as m, i} - {@const pct = maxMinutes > 0 ? (m.minutes / maxMinutes) * 100 : 0} -
- - {m.minutes > 0 ? formatDuration(m.minutes) : ''} - -
-
-
- {m.month} -
- {/each} -
-
- - - {#if data.recentSessions.length > 0} -
-

Recent Sessions

-
- {#each data.recentSessions as s (s.id)} -
-
-

Book {s.bookId}

-

{formatSessionDate(s.startedAt)}

-
-
- {formatSessionDuration(s.durationSeconds)} -
-
- {/each} -
-
- {/if} -
- - -
-
-

Reading Goal

- - {#if currentYearGoal} - -
-
- - - - -
- {data.booksFinished} - of {currentYearGoal.targetBooks} -
-
-

- {Math.round(goalProgress * 100)}% complete · {new Date().getFullYear()} -

-
- {:else} - -
-

Set a yearly reading goal to track your progress.

-
- - {goalTarget} - -
-

books in {new Date().getFullYear()}

- -
- {/if} -
- - -
-

Time Spent Reading

-

{formatDuration(data.totalReadingMinutes)}

-

total across {data.sessionCount} sessions

-
- - - -
-
-
- - diff --git a/src/routes/calendar/+page.server.ts b/src/routes/calendar/+page.server.ts deleted file mode 100644 index 30bb4956..00000000 --- a/src/routes/calendar/+page.server.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { PageServerLoad } from './$types'; -import type { CalendarItem } from '$lib/adapters/types'; -import { getMissingCategories } from '$lib/server/onboarding'; - -export const load: PageServerLoad = async ({ fetch, url, locals }) => { - const days = parseInt(url.searchParams.get('days') ?? '14', 10); - const res = await fetch(`/api/calendar?days=${days}`); - const items: CalendarItem[] = res.ok ? await res.json() : []; - - // Group by date for the full calendar page - const byDate: Record = {}; - for (const item of items) { - const date = item.releaseDate?.split('T')[0] ?? 'Unknown'; - if (!byDate[date]) byDate[date] = []; - byDate[date].push(item); - } - - return { items, byDate, days, missingCategories: locals.user?.isAdmin ? getMissingCategories(['automation']) : [] }; -}; diff --git a/src/routes/calendar/+page.svelte b/src/routes/calendar/+page.svelte deleted file mode 100644 index 0ef30b8b..00000000 --- a/src/routes/calendar/+page.svelte +++ /dev/null @@ -1,453 +0,0 @@ - - - - Calendar — Nexus - - -{#if data.missingCategories?.length} - -{/if} - -
- -
-
- - - - - - -

Calendar

-
-
- {#each dayOptions as d (d)} - - {/each} -
-
- - - {#if data.items.length === 0} -
- - - - - - -

Nothing on the horizon

-

No upcoming releases found in the next {activeDays} days. Connect Sonarr or Radarr to track releases.

-
- {:else} -
- {#each sortedDates as date (date)} - {@const items = data.byDate[date]} - - {/each} -
- {/if} -
- - diff --git a/src/routes/collection/[backend]/[id]/+page.server.ts b/src/routes/collection/[backend]/[id]/+page.server.ts new file mode 100644 index 00000000..96227ad3 --- /dev/null +++ b/src/routes/collection/[backend]/[id]/+page.server.ts @@ -0,0 +1,30 @@ +import { error } from '@sveltejs/kit'; +import { registryV2 } from '$lib/adapters/v2'; +import { resolveServiceConfig } from '$lib/server/v2-services'; +import type { PageServerLoad } from './$types'; + +/** + * An immutable collection view — a series' episodes, an album's tracks. Read-only: + * the children come straight from the backend (adapter.getChildren), nothing is + * stored. Mutable playlists are a separate, persisted feature. + */ +export const load: PageServerLoad = async ({ params, locals }) => { + if (!locals.user) throw error(401, 'Unauthorized'); + + const backend = params.backend; + const adapter = registryV2.get(backend); + if (!adapter) throw error(404, `Unknown backend "${backend}"`); + const config = resolveServiceConfig(backend); + if (!config) throw error(404, `Backend "${backend}" is not configured`); + + // The parent (for the header) drives the type so getChildren picks the right + // backend call; fetch it first, then its children. + const item = adapter.getItem ? await adapter.getItem(config, params.id) : null; + const children = adapter.getChildren + ? await adapter.getChildren(config, params.id, item?.type) + : []; + + if (!item && children.length === 0) throw error(404, 'Collection not found'); + + return { backend, item, children }; +}; diff --git a/src/routes/collection/[backend]/[id]/+page.svelte b/src/routes/collection/[backend]/[id]/+page.svelte new file mode 100644 index 00000000..33902ac4 --- /dev/null +++ b/src/routes/collection/[backend]/[id]/+page.svelte @@ -0,0 +1,170 @@ + + +{item?.title ?? 'Collection'} · Nexus + +
+
+
+
+ Home +
+
+ {#if poster(item)} + {item?.title} ((e.currentTarget as HTMLImageElement).style.display = 'none')} /> + {:else} + + {/if} +
+
+ {typeLabel(item?.type) || 'Collection'}{#if item?.year} · {item.year}{/if} +

{item?.title ?? 'Collection'}

+ {children.length} {children.length === 1 ? 'item' : 'items'}{#if playableCount && playableCount !== children.length} · {playableCount} playable{/if} + {#if item?.description}

{item.description}

{/if} +
+
+
+
+ +
+ {#if children.length === 0} +
Nothing in this collection.
+ {:else} +
    + {#each children as c, i (c.id)} +
  1. + +
  2. + {/each} +
+ {/if} +
+
+ + diff --git a/src/routes/discover/+page.server.ts b/src/routes/discover/+page.server.ts deleted file mode 100644 index 23415e15..00000000 --- a/src/routes/discover/+page.server.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { getMissingCategories } from '$lib/server/onboarding'; - -export const load: PageServerLoad = async ({ fetch, url, locals }) => { - const category = url.searchParams.get('category') ?? 'trending'; - const genreId = url.searchParams.get('genreId') ?? ''; - - const [discoverRes, movieGenresRes, tvGenresRes] = await Promise.all([ - fetch(`/api/discover?category=${category}&genreId=${genreId}&page=1`), - fetch('/api/discover/genres?type=movie'), - fetch('/api/discover/genres?type=tv') - ]); - - const discover = discoverRes.ok ? await discoverRes.json() : { items: [], hasMore: false }; - const movieGenres = movieGenresRes.ok ? await movieGenresRes.json() : []; - const tvGenres = tvGenresRes.ok ? await tvGenresRes.json() : []; - - return { discover, movieGenres, tvGenres, category, genreId, missingCategories: locals.user?.isAdmin ? getMissingCategories(['requests']) : [] }; -}; diff --git a/src/routes/discover/+page.svelte b/src/routes/discover/+page.svelte deleted file mode 100644 index 077cd276..00000000 --- a/src/routes/discover/+page.svelte +++ /dev/null @@ -1,338 +0,0 @@ - - - - Discover — Nexus - - - -{#if data.missingCategories?.length} - -{/if} - -
- -
-

Discover

-

Explore trending and upcoming titles across your services.

-
- - -
-
- {#each tabs as tab, i (tab.id)} - - {/each} -
- - - {#if showGenreFilter && genres.length > 0} -
- - -
- {/if} -
- - -
- {#if items.length === 0 && !loading} -
-
- - - - -
-

Nothing to discover

-

Connect a service like Overseerr to populate discover results.

- Connect a Service -
- {:else} -
- {#each items as item, i (item.sourceId + '-' + i)} -
- - {#if item.rating} -
- - {item.rating.toFixed(1)} -
- {/if} - - - {#if item.status === 'available'} -
- In Library -
- {:else if item.status === 'requested'} -
- Requested -
- {:else if item.serviceType === 'overseerr'} - {@const reqState = requesting[item.id] ?? 'idle'} -
- {#if reqState === 'done'} - Requested - {:else if reqState === 'loading'} - - Requesting... - - {:else if reqState === 'error'} - Failed - {:else} - - {/if} -
- {/if} - - - - -
- {#if item.genres?.length} -

- {#if item.year}{item.year} · {/if}{item.genres[0]} -

- {/if} -
-
- {/each} -
- - - - {/if} -
-
- - diff --git a/src/routes/franchise/+page.server.ts b/src/routes/franchise/+page.server.ts deleted file mode 100644 index e39ac99b..00000000 --- a/src/routes/franchise/+page.server.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { getFranchiseData } from '$lib/server/franchise'; - -export const load: PageServerLoad = async ({ url, locals }) => { - const name = url.searchParams.get('name') ?? ''; - if (!name || !locals.user) return { franchise: null, name: '' }; - const franchise = await getFranchiseData(name, locals.user.id); - return { franchise, name }; -}; diff --git a/src/routes/franchise/+page.svelte b/src/routes/franchise/+page.svelte deleted file mode 100644 index c01adc34..00000000 --- a/src/routes/franchise/+page.svelte +++ /dev/null @@ -1,372 +0,0 @@ - - - - {franchise ? `${franchise.name} — Nexus` : 'Franchise — Nexus'} - - -
- - - - {#if !data.name} - -
-
- -
-

Explore a franchise

-

Search for a franchise name to find all related movies, shows, books, games, and more across your library.

-
- {:else if !franchise || totalCount === 0} - -
-
- -
-

No results for "{data.name}"

-

Try a different franchise name or check your connected services.

-
- {:else} - -
-

Explore: {franchise.name}

- {totalCount} item{totalCount !== 1 ? 's' : ''} found -
- - -
- {#each sections as section (section.key)} -
-

{section.label}

-
- {#each section.items as item (item.sourceId + ':' + item.serviceId)} - -
- {#if item.poster} - - {:else} - - {/if} -
-
- {item.title} - {#if item.year} - {item.year} - {/if} -
-
- {/each} -
-
- {/each} -
- {/if} -
- - diff --git a/src/routes/friends/+page.server.ts b/src/routes/friends/+page.server.ts deleted file mode 100644 index 02cbdde1..00000000 --- a/src/routes/friends/+page.server.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { getFriends, getPendingRequests, getBlockedUserIds } from '$lib/server/social'; -import { getOnlineUserIds } from '$lib/server/ws'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ locals }) => { - if (!locals.user) return { friends: [], requests: [], blocked: [], onlineIds: new Set() }; - const userId = locals.user.id; - const friends = getFriends(userId); - const requests = getPendingRequests(userId); - const blocked = getBlockedUserIds(userId); - const onlineIds = getOnlineUserIds(); - return { friends, requests, blocked, onlineIds }; -}; diff --git a/src/routes/friends/+page.svelte b/src/routes/friends/+page.svelte deleted file mode 100644 index 00140c81..00000000 --- a/src/routes/friends/+page.svelte +++ /dev/null @@ -1,231 +0,0 @@ - - - - Friends — Nexus - - -
-
-
-

Friends

-

{data.friends.length} friend{data.friends.length === 1 ? '' : 's'}

-
-
- - -
-
- - -
-
- - {#if searchResults.length > 0} -
- {#each searchResults as user} -
-
-

{user.displayName}

-

@{user.username}

-
- -
- {/each} -
- {/if} - - -
- - -
- - {#if tab === 'friends'} - {#if data.friends.length === 0} -
-
- -
-

No friends yet

-

Search for users above to add friends.

-
- {:else} - - {#if onlineFriends.length > 0} -
-

Online — {onlineFriends.length}

-
- {#each onlineFriends as friend} -
-
-
- {friend.displayName.slice(0, 1).toUpperCase()} -
- -
-
-

{friend.displayName}

-

@{friend.username}

-
-
- {/each} -
-
- {/if} - - - {#if offlineFriends.length > 0} -
-

Offline — {offlineFriends.length}

-
- {#each offlineFriends as friend} -
-
- {friend.displayName.slice(0, 1).toUpperCase()} -
-
-

{friend.displayName}

-

@{friend.username}

-
-
- {/each} -
-
- {/if} - {/if} - {:else if tab === 'requests'} - {#if data.requests.length === 0} -
-
- -
-

No pending requests

-

Friend requests you receive will appear here.

-
- {:else} -
- {#each data.requests as req} -
-
-
- {req.fromDisplayName.slice(0, 1).toUpperCase()} -
-
-

{req.fromDisplayName}

-

@{req.fromUsername}

-
-
-
- - -
-
- {/each} -
- {/if} - {/if} -
diff --git a/src/routes/games/+page.server.ts b/src/routes/games/+page.server.ts deleted file mode 100644 index ef1d9c22..00000000 --- a/src/routes/games/+page.server.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { getLibraryItems, getConfigsForMediaType } from '$lib/server/services'; -import { registry } from '$lib/adapters/registry'; -import { getPlatforms } from '$lib/adapters/romm'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { getDb, schema } from '$lib/db'; -import { and, eq, desc } from 'drizzle-orm'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ url, locals }) => { - const sortBy = url.searchParams.get('sort') || 'title'; - const platformParam = url.searchParams.get('platform'); - const platformId = platformParam ? Number(platformParam) : undefined; - const userId = locals.user?.id; - - const rommConfigs = getConfigsForMediaType('game'); - const hasGameService = rommConfigs.length > 0; - - // Resolve user credentials for each RomM instance - const rommCreds = rommConfigs.map((c) => - userId ? getUserCredentialForService(userId, c.id) ?? undefined : undefined - ); - - const adapter = registry.get('romm'); - - const [libraryResult, ...platformResults] = await Promise.all([ - getLibraryItems({ type: 'game', sortBy, limit: 200, platformId }, userId), - ...rommConfigs.map((c, i) => getPlatforms(c, rommCreds[i])) - ]); - - const platforms = platformResults.flat(); - - let collections: { id: number; name: string; description?: string; romIds: number[] }[] = []; - try { - const allCollections = await Promise.all( - rommConfigs.map((c, i) => adapter?.getSubItems?.(c, '', 'collection', {}, rommCreds[i]).then(r => r?.items ?? []) ?? Promise.resolve([])) - ); - collections = allCollections.flat().map((c: any) => ({ - id: c.id, - name: c.name, - description: c.description, - romIds: c.roms ?? [] - })); - } catch { /* ignore */ } - - // Enrich items with per-user session state (same pattern as books). RomM's - // `userStatus` stays as an advisory hint but Continue Playing is driven by - // play_sessions now. - if (userId) { - const db = getDb(); - const gameSessions = db.select({ - mediaId: schema.playSessions.mediaId, - serviceId: schema.playSessions.serviceId, - updatedAt: schema.playSessions.updatedAt, - endedAt: schema.playSessions.endedAt, - completed: schema.playSessions.completed - }) - .from(schema.playSessions) - .where(and( - eq(schema.playSessions.userId, userId), - eq(schema.playSessions.mediaType, 'game') - )) - .orderBy(desc(schema.playSessions.updatedAt)) - .all(); - const latestBySource = new Map(); - for (const row of gameSessions) { - const key = `${row.serviceId}:${row.mediaId}`; - if (!latestBySource.has(key)) latestBySource.set(key, row); - } - for (const item of libraryResult.items) { - const s = latestBySource.get(`${item.serviceId}:${item.sourceId}`); - if (!s) continue; - item.metadata = { - ...(item.metadata ?? {}), - lastPlayedAtMs: s.updatedAt, - sessionOpen: s.endedAt == null, - sessionCompleted: !!s.completed - }; - } - } - - return { - items: libraryResult.items, - total: libraryResult.total, - sortBy, - hasGameService, - platforms, - selectedPlatform: platformId ?? null, - collections - }; -}; diff --git a/src/routes/games/+page.svelte b/src/routes/games/+page.svelte deleted file mode 100644 index 8eb06bd1..00000000 --- a/src/routes/games/+page.svelte +++ /dev/null @@ -1,878 +0,0 @@ - - - - Games — Nexus - - -
- {#if hero} - -
-
-
- - {#if hero.year}{hero.year}{/if} - {#if hero.rating} - · - - - {hero.rating.toFixed(1)} - - {/if} - {#if hero.metadata?.platform} - · - {hero.metadata.platform} - {/if} -
-

{hero.title}

- {#if hero.genres?.length} -
- {#each hero.genres.slice(0, 3) as genre (genre)} - {genre} - {/each} -
- {/if} - {#if hero.description} -

{hero.description}

- {/if} -
- More Info -
-
- -
-
- {/if} - -
-
-
-

Games

-

{data.total} items in your collection

-
- - - Stats - -
- - - {#if inProgress.length > 0 && data.selectedPlatform == null && !localQuery} -
-

Continue Playing

-
- {#each inProgress.slice(0, 10) as item (item.id)} -
- - {#if item.metadata?.userStatus} - {@const status = item.metadata.userStatus as string} -
- {/if} -
- {/each} -
-
- {/if} - - - {#if data.platforms.length > 0} -
- - All - - {#each data.platforms.filter(p => p.rom_count > 0).sort((a, b) => b.rom_count - a.rom_count) as platform} - - {#if platform.url_logo} - - {/if} - {platform.display_name} - {platform.rom_count} - - {/each} -
- {/if} - - -
- -
- Sort by -
- {#each sortOptions as s} - - {s.label} - - {/each} -
-
- - -
- - -
- - -
-
- - -
- - - - - {#if localQuery} - - {/if} -
-
- - {#if showFilterPanel} -
- -
- {/if} - - {#if filtered.length === 0} -
-
- - - -
-

No games found

-

- {data.items.length === 0 - ? data.hasGameService - ? 'Your game library is empty, still syncing, or RomM is unavailable right now.' - : 'Connect RomM to see your game collection here.' - : 'Try adjusting your search or filter.'} -

- {#if data.items.length === 0 && !data.hasGameService} - Connect a Service - {/if} -
- {:else if viewMode === 'list'} - - - {:else} - -
- {#each filtered as item (item.id)} -
- - {#if item.metadata?.userStatus} - {@const status = item.metadata.userStatus as string} -
- {/if} - {#if item.metadata?.platform} - {item.metadata.platform} - {/if} -
- {/each} -
- {/if} - - -
-
-

Collections

- -
- {#if data.collections.length === 0} -

No collections yet. Create one to organize your games.

- {/if} - {#each data.collections as collection (collection.id)} - {@const collectionItems = data.items.filter((i) => collection.romIds.includes(Number(i.sourceId)))} -
-
-

{collection.name}

- {collectionItems.length} games -
- - -
-
- {#if collectionItems.length > 0} -
- {#each collectionItems.slice(0, 12) as item (item.id)} -
- -
- {/each} -
- {:else} -

No games in this collection yet.

- {/if} -
- {/each} -
-
-
- - { editorOpen = false; }} - onsave={handleSave} -/> - - diff --git a/src/routes/games/platform/[slug]/+page.server.ts b/src/routes/games/platform/[slug]/+page.server.ts deleted file mode 100644 index fc7d07f5..00000000 --- a/src/routes/games/platform/[slug]/+page.server.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { getLibraryItems, getConfigsForMediaType } from '$lib/server/services'; -import { getPlatforms } from '$lib/adapters/romm'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { error } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ params, url, locals }) => { - const sortBy = url.searchParams.get('sort') || 'title'; - const userId = locals.user?.id; - const rommConfigs = getConfigsForMediaType('game'); - if (rommConfigs.length === 0) throw error(404, 'No RomM service configured'); - - const config = rommConfigs[0]; - const userCred = userId ? getUserCredentialForService(userId, config.id) ?? undefined : undefined; - const platforms = await getPlatforms(config, userCred); - const platform = platforms.find((p) => p.slug === params.slug); - if (!platform) throw error(404, 'Platform not found'); - - const libraryResult = await getLibraryItems( - { type: 'game', sortBy, limit: 500, platformId: platform.id }, - userId - ); - - // Compute stats - const items = libraryResult.items; - const genreMap = new Map(); - let ratingSum = 0; - let ratingCount = 0; - for (const item of items) { - if (item.rating) { ratingSum += item.rating; ratingCount++; } - for (const g of item.genres ?? []) { - genreMap.set(g, (genreMap.get(g) ?? 0) + 1); - } - } - - const topGenres = [...genreMap.entries()] - .sort((a, b) => b[1] - a[1]) - .slice(0, 5) - .map(([name, count]) => ({ name, count })); - - return { - platform, - items, - total: libraryResult.total, - sortBy, - stats: { - totalGames: items.length, - avgRating: ratingCount > 0 ? Math.round((ratingSum / ratingCount) * 10) / 10 : null, - topGenres - } - }; -}; diff --git a/src/routes/games/platform/[slug]/+page.svelte b/src/routes/games/platform/[slug]/+page.svelte deleted file mode 100644 index 3bbe1655..00000000 --- a/src/routes/games/platform/[slug]/+page.svelte +++ /dev/null @@ -1,115 +0,0 @@ - - - - {data.platform.display_name} — Games — Nexus - - -
- -
- - - - {#if data.platform.url_logo} - - {/if} -
-

{data.platform.display_name}

-

{data.total} games

-
-
- - -
-
- {data.stats.totalGames} - Games -
- {#if data.stats.avgRating} -
- {data.stats.avgRating} - Avg Rating -
- {/if} - {#each data.stats.topGenres.slice(0, 3) as genre} -
- {genre.count} - {genre.name} -
- {/each} -
- - -
- Sort by -
- {#each sortOptions as s} - - {s.label} - - {/each} -
-
- - - {#if data.items.length === 0} -
-

No games found

-

No games available for this platform.

-
- {:else} -
- {#each data.items as item (item.id)} - - {/each} -
- {/if} -
- - diff --git a/src/routes/games/stats/+page.server.ts b/src/routes/games/stats/+page.server.ts deleted file mode 100644 index 43a1f77e..00000000 --- a/src/routes/games/stats/+page.server.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { getLibraryItems, getConfigsForMediaType } from '$lib/server/services'; -import { getPlatforms } from '$lib/adapters/romm'; -import { getUserCredentialForService } from '$lib/server/auth'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ locals }) => { - const userId = locals.user?.id; - const rommConfigs = getConfigsForMediaType('game'); - - const [libraryResult, ...platformResults] = await Promise.all([ - getLibraryItems({ type: 'game', limit: 2000 }, userId), - ...rommConfigs.map((c) => { - const cred = userId ? getUserCredentialForService(userId, c.id) ?? undefined : undefined; - return getPlatforms(c, cred); - }) - ]); - - const items = libraryResult.items; - const platforms = platformResults.flat(); - - // Platform breakdown - const platformMap = new Map(); - for (const item of items) { - const pName = (item.metadata?.platform as string) ?? 'Unknown'; - const pSlug = (item.metadata?.platformSlug as string) ?? 'unknown'; - const existing = platformMap.get(pName); - if (existing) { - existing.count++; - } else { - const pInfo = platforms.find((p) => p.slug === pSlug); - platformMap.set(pName, { name: pName, slug: pSlug, count: 1, logo: pInfo?.url_logo }); - } - } - const platformBreakdown = [...platformMap.values()].sort((a, b) => b.count - a.count); - - // Status breakdown - const statusMap = new Map(); - for (const item of items) { - const status = (item.metadata?.userStatus as string) ?? 'unset'; - statusMap.set(status, (statusMap.get(status) ?? 0) + 1); - } - const statusBreakdown = [...statusMap.entries()] - .map(([status, count]) => ({ status, count })) - .sort((a, b) => b.count - a.count); - - // Genre breakdown - const genreMap = new Map(); - for (const item of items) { - for (const g of item.genres ?? []) { - genreMap.set(g, (genreMap.get(g) ?? 0) + 1); - } - } - const genreBreakdown = [...genreMap.entries()] - .map(([genre, count]) => ({ genre, count })) - .sort((a, b) => b.count - a.count) - .slice(0, 15); - - // Top rated - const topRated = items - .filter((i) => i.rating != null && i.rating > 0) - .sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0)) - .slice(0, 10); - - // Averages - let ratingSum = 0; - let ratingCount = 0; - for (const item of items) { - if (item.rating) { ratingSum += item.rating; ratingCount++; } - } - - const finishedCount = items.filter((i) => - i.metadata?.userStatus === 'finished' || i.metadata?.userStatus === 'completed' - ).length; - - return { - totalGames: items.length, - avgRating: ratingCount > 0 ? Math.round((ratingSum / ratingCount) * 10) / 10 : null, - finishedCount, - completionRate: items.length > 0 ? Math.round((finishedCount / items.length) * 100) : 0, - platformBreakdown, - statusBreakdown, - genreBreakdown, - topRated - }; -}; diff --git a/src/routes/games/stats/+page.svelte b/src/routes/games/stats/+page.svelte deleted file mode 100644 index 523aa7d2..00000000 --- a/src/routes/games/stats/+page.svelte +++ /dev/null @@ -1,214 +0,0 @@ - - - - Game Stats — Nexus - - -
- -
- - - -

Game Stats

-
- - -
- - {#if data.avgRating} - - {/if} - - - -
- -
- -
-

Platform Distribution

-
- {#each data.platformBreakdown as p} - -
- {#if p.logo} - - {/if} - {p.name} -
-
-
-
- {p.count} -
- {/each} -
-
- - -
-

Status Breakdown

-
- {#each data.statusBreakdown as s} -
-
-
- {s.status === 'unset' ? 'No Status' : s.status} -
-
-
-
- {s.count} -
- {/each} -
-
- - -
-

Top Genres

-
- {#each data.genreBreakdown as g} -
- {g.genre} -
-
-
- {g.count} -
- {/each} -
-
- - -
-

Top Rated

-
- {#each data.topRated as item, i (item.id)} - {@const detailUrl = `/media/${item.type}/${item.sourceId}?service=${item.serviceId}`} - - {i + 1} - {#if item.poster} - - {/if} -
-

{item.title}

- {#if item.metadata?.platform} -

{item.metadata.platform}

- {/if} -
- {#if item.rating} -
- - {item.rating.toFixed(1)} -
- {/if} -
- {/each} -
-
-
-
- - diff --git a/src/routes/invite/+page.server.ts b/src/routes/invite/+page.server.ts deleted file mode 100644 index ffc62018..00000000 --- a/src/routes/invite/+page.server.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { fail, redirect } from '@sveltejs/kit'; -import { - COOKIE_NAME, - createSession, - createUser, - getUserByUsername, - upsertUserCredential, - validateInviteCode, - consumeInviteCode -} from '$lib/server/auth'; -import { getEnabledConfigs, getServiceConfig } from '$lib/server/services'; -import { registry } from '$lib/adapters/registry'; -import { getDb, schema } from '$lib/db'; -import { and, eq } from 'drizzle-orm'; -import type { Actions, PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ url }) => { - // Lifecycle gate (already-logged-in → /) lives in resolveRedirect (#32). - const code = url.searchParams.get('code') ?? ''; - - if (!code) { - return { valid: false, error: 'No invite code provided', authServices: [] }; - } - - const invite = validateInviteCode(code); - if (!invite) { - return { valid: false, error: 'This invite link is invalid or has expired', authServices: [] }; - } - - const authServices = getEnabledConfigs() - .filter((c) => { - const a = registry.get(c.type); - return (c.type === 'jellyfin' || c.type === 'plex') && a?.authenticateUser; - }) - .map((c) => ({ id: c.id, name: c.name, type: c.type })); - - return { valid: true, code, authServices }; -}; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Find the Nexus userId that has this externalUserId linked to this service */ -function findUserByExternalId(serviceId: string, externalUserId: string): string | null { - const db = getDb(); - const row = db - .select({ userId: schema.userServiceCredentials.userId }) - .from(schema.userServiceCredentials) - .where( - and( - eq(schema.userServiceCredentials.serviceId, serviceId), - eq(schema.userServiceCredentials.externalUserId, externalUserId) - ) - ) - .get(); - return row?.userId ?? null; -} - -/** Generate a unique username by appending a suffix */ -function generateUniqueUsername(base: string, suffix: string): string { - const candidate = `${base}_${suffix}`; - if (!getUserByUsername(candidate)) return candidate; - for (let i = 2; i < 100; i++) { - const attempt = `${base}_${suffix}${i}`; - if (!getUserByUsername(attempt)) return attempt; - } - return `${base}_${Date.now()}`; -} - -// --------------------------------------------------------------------------- -// Actions -// --------------------------------------------------------------------------- - -export const actions: Actions = { - default: async ({ request, cookies }) => { - const data = await request.formData(); - const code = (data.get('code') as string)?.trim(); - const authType = data.get('authType') as string | null; - - if (!code) { - return fail(400, { error: 'Missing invite code' }); - } - - const invite = validateInviteCode(code); - if (!invite) { - return fail(400, { error: 'This invite link is invalid or has expired' }); - } - - // ── Service authentication (Jellyfin / Plex) ──────────────────────── - if (authType === 'service') { - const serviceId = data.get('serviceId') as string; - const username = (data.get('username') as string)?.trim(); - const password = data.get('password') as string; - - if (!serviceId || !username || !password) { - return fail(400, { error: 'Service, username, and password are required', authType: 'service', serviceId }); - } - - // Look up service and adapter - const config = getServiceConfig(serviceId); - if (!config) { - return fail(400, { error: 'Service not found', authType: 'service', serviceId }); - } - const adapter = registry.get(config.type); - if (!adapter?.authenticateUser) { - return fail(400, { error: 'This service does not support authentication', authType: 'service', serviceId }); - } - - // Authenticate against the external service - let authResult: { accessToken: string; externalUserId: string; externalUsername: string }; - try { - authResult = await adapter.authenticateUser(config, username, password); - } catch (e) { - const msg = e instanceof Error ? e.message : 'Authentication failed'; - return fail(401, { error: msg, authType: 'service', serviceId, username }); - } - - // If a Nexus user already has this externalUserId linked, redirect to login - const existingUserId = findUserByExternalId(serviceId, authResult.externalUserId); - if (existingUserId) { - throw redirect(303, '/login?message=account-exists'); - } - - // If a Nexus user with the same username exists, redirect to login - if (getUserByUsername(authResult.externalUsername)) { - throw redirect(303, '/login?message=account-exists'); - } - - // Create a new Nexus account — invited users are always active - const typeSuffix = config.type === 'jellyfin' ? 'jf' : 'plex'; - let newUsername = authResult.externalUsername; - if (getUserByUsername(newUsername)) { - newUsername = generateUniqueUsername(newUsername, typeSuffix); - } - - const randomPassword = crypto.randomUUID(); - const userId = createUser(newUsername, authResult.externalUsername, randomPassword, false, { - authProvider: config.type, - externalId: authResult.externalUserId, - status: 'active' - }); - - // Link the credential - upsertUserCredential(userId, serviceId, { - accessToken: authResult.accessToken, - externalUserId: authResult.externalUserId, - externalUsername: authResult.externalUsername - }); - - // Consume the invite code - consumeInviteCode(code); - - const token = createSession(userId); - cookies.set(COOKIE_NAME, token, { - path: '/', - httpOnly: true, - sameSite: 'lax', - secure: false, - maxAge: 30 * 86_400 - }); - - throw redirect(303, '/'); - } - - // ── Local registration ────────────────────────────────────────────── - const username = (data.get('username') as string)?.trim(); - const displayName = (data.get('displayName') as string)?.trim(); - const password = data.get('password') as string; - const confirm = data.get('confirm') as string; - - if (!username || !displayName || !password) { - return fail(400, { error: 'All fields are required' }); - } - if (password.length < 6) { - return fail(400, { error: 'Password must be at least 6 characters' }); - } - if (password !== confirm) { - return fail(400, { error: 'Passwords do not match' }); - } - - try { - const userId = createUser(username, displayName, password, false); - consumeInviteCode(code); - - const token = createSession(userId); - cookies.set(COOKIE_NAME, token, { - path: '/', - httpOnly: true, - sameSite: 'lax', - secure: false, - maxAge: 30 * 86_400 - }); - - throw redirect(303, '/'); - } catch (e) { - // Re-throw redirects - if (e && typeof e === 'object' && 'status' in e) throw e; - - const msg = String(e); - if (msg.includes('UNIQUE')) { - return fail(400, { error: 'Username already taken' }); - } - return fail(500, { error: 'Failed to create account' }); - } - } -}; diff --git a/src/routes/invite/+page.svelte b/src/routes/invite/+page.svelte deleted file mode 100644 index 27662fe5..00000000 --- a/src/routes/invite/+page.svelte +++ /dev/null @@ -1,227 +0,0 @@ - - - - Join Nexus - - -
-
- -
-
- - - -
-
-

Join Nexus

-

You've been invited — create your account.

-
-
- - {#if !data.valid} -
-
- - - - -
-

{data.error}

- Go to Login -
- {:else} - {#if data.authServices.length > 0 && !activeServiceId} - -
- {#each data.authServices as svc} - - {/each} -
- - -
-
- or create with password -
-
- {/if} - - {#if activeServiceId && activeService} - -
(loading = true)}> - - - - -
- - {serviceIcon(activeService.type)} - -
-

Sign up with {activeService.name}

-

- {#if activeService.type === 'plex'} - Paste your Plex token (get one at plex.tv/security) - {:else} - Use your Jellyfin credentials - {/if} -

-
-
- - {#if form?.error && form?.authType === 'service'} -
- {form.error} -
- {/if} - -
- - -
- -
- - -
- - - - -
- {:else} -
(loading = true)}> - - - {#if form?.error && form?.authType !== 'service'} -
- {form.error} -
- {/if} - -
- - -
-
- - -
-
- - -
-
- - -
- - -
- -

- Already have an account? Sign in -

- {/if} - {/if} - -
- -
-
-
diff --git a/src/routes/library/+layout.server.ts b/src/routes/library/+layout.server.ts deleted file mode 100644 index 417bd474..00000000 --- a/src/routes/library/+layout.server.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import type { LayoutServerLoad } from './$types'; - -export const load: LayoutServerLoad = async ({ locals, url, parent }) => { - if (!locals.user) throw redirect(302, '/login'); - - // Redirect /library to /library/watchlist - if (url.pathname === '/library' || url.pathname === '/library/') { - throw redirect(302, '/library/watchlist'); - } - - // unseenShares now lives on the root layout so the badge renders everywhere; - // re-expose via parent() for any child routes that still read data.unseenShares. - const { unseenShares } = await parent(); - return { unseenShares }; -}; diff --git a/src/routes/library/+layout.svelte b/src/routes/library/+layout.svelte deleted file mode 100644 index 7a542552..00000000 --- a/src/routes/library/+layout.svelte +++ /dev/null @@ -1,57 +0,0 @@ - - - - Library — Nexus - - -
- -
-

Library

-

Your personal collection, curated just for you.

- - - -
- - {@render children()} -
diff --git a/src/routes/library/[type]/+page.server.ts b/src/routes/library/[type]/+page.server.ts new file mode 100644 index 00000000..e9bad38f --- /dev/null +++ b/src/routes/library/[type]/+page.server.ts @@ -0,0 +1,63 @@ +import { error } from '@sveltejs/kit'; +import { registryV2 } from '$lib/adapters/v2'; +import type { UnifiedMedia } from '$lib/adapters/types'; +import { resolveServiceConfig } from '$lib/server/v2-services'; +import type { PageServerLoad } from './$types'; + +// Per-type library pages. Each route maps to a backend + a MediaType filter for the +// LibraryQuery. Books/Games have no backend wired in phase-0 yet, so they render an +// honest empty state rather than fabricated content. +const TYPE_MAP: Record< + string, + { backend: string; media: string; accept: string[]; title: string } +> = { + movies: { backend: 'jellyfin', media: 'movie', accept: ['movie'], title: 'Movies' }, + shows: { backend: 'jellyfin', media: 'show', accept: ['show', 'series'], title: 'Shows' }, + music: { backend: 'jellyfin', media: 'music', accept: ['music', 'album'], title: 'Music' }, + videos: { backend: 'invidious', media: 'video', accept: ['video'], title: 'Videos' }, + books: { backend: '', media: 'book', accept: ['book'], title: 'Books' }, + games: { backend: '', media: 'game', accept: ['game'], title: 'Games' } +}; + +const hasBackdrop = (item: UnifiedMedia): boolean => Boolean(item.backdrop); + +export const load: PageServerLoad = async ({ params }) => { + const map = TYPE_MAP[params.type]; + if (!map) throw error(404, 'Unknown library'); + + let catalog: UnifiedMedia[] = []; + let recentlyFiled: UnifiedMedia[] = []; + let hasBackend = false; + + if (map.backend) { + const config = resolveServiceConfig(map.backend); + const adapter = registryV2.get(map.backend); + if (config && adapter) { + hasBackend = true; + try { + if (adapter.getLibrary) { + const page = await adapter.getLibrary(config, { type: map.media, limit: 120 }); + catalog = page.items; + } + if (adapter.getRecentlyAdded) { + const recent = await adapter.getRecentlyAdded(config); + recentlyFiled = recent.filter((i) => map.accept.includes(i.type)).slice(0, 12); + } + } catch (err) { + console.warn(`[library/${params.type}] skipping ${map.backend}:`, err); + } + } + } + + const hero = recentlyFiled.find(hasBackdrop) ?? catalog.find(hasBackdrop) ?? catalog[0] ?? null; + + return { + type: params.type, + title: map.title, + catalog, + recentlyFiled, + hero, + count: catalog.length, + hasBackend + }; +}; diff --git a/src/routes/library/[type]/+page.svelte b/src/routes/library/[type]/+page.svelte new file mode 100644 index 00000000..635255c9 --- /dev/null +++ b/src/routes/library/[type]/+page.svelte @@ -0,0 +1,812 @@ + + +{data.title} · Nexus + +
+
+
+
+ + Nexus + + + + P +
+ +
+ + +
+
+ {#each TYPES as t (t.key)} + {t.label} + {/each} + ▷ YouTube +
+ +
+
{data.title} Collection
+

{data.title}

+
+ {#if data.count}{data.count} entries · filed newest first{:else}awaiting catalog{/if} +
+
+
+ + {#if !data.hasBackend} +
+
+
No {data.title} library connected yet
+
Once a backend for this type is wired up, your catalog files in here.
+
+ {:else if data.count === 0} +
+
+
Nothing filed here yet
+
This library is connected but came back empty.
+
+ {:else} + {#if hero} +
+
+
Now Showing
+
+
{callNumber(hero, 0)}
+
{hero.title}
+
+ {typeLabel(hero.type)}{#if hero.year} · {hero.year}{/if} +
+
+ {#if isPlayable(hero)} + ▶ {hero.progress ? 'Resume' : 'Play'} + {/if} + {#if isOpenable(hero)} + Details + {/if} +
+
+
+
+ {/if} + + {#if data.recentlyFiled.length} +
+
+ Recently Filed + +
+ last 30 days +
+
+ {#each data.recentlyFiled as item, i (item.id)} + {@render card(item, i)} + {/each} +
+
+ {/if} + +
+
+ The Catalog + +
+
+
+ {#each data.catalog as item, i (item.id)} + {@render card(item, i)} + {/each} +
+
+ {/if} +
+
+ + +
+
+ +{#snippet card(item: UnifiedMedia, i: number)} + +
+ {#if item.poster} + {item.title} + {/if} + {callNumber(item, i)} + {#if item.year}{item.year}{/if} + {#if isPlayable(item)}
{/if} + {#if item.progress}
{/if} +
+
+
{item.title}
+
{callNumber(item, i)}
+
+
+{/snippet} + + diff --git a/src/routes/library/catalogs/+page.server.ts b/src/routes/library/catalogs/+page.server.ts deleted file mode 100644 index f634f4d8..00000000 --- a/src/routes/library/catalogs/+page.server.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ fetch }) => { - const res = await fetch('/api/library/catalogs'); - const collections = res.ok ? await res.json() : []; - return { collections }; -}; diff --git a/src/routes/library/catalogs/+page.svelte b/src/routes/library/catalogs/+page.svelte deleted file mode 100644 index 7b41e5b6..00000000 --- a/src/routes/library/catalogs/+page.svelte +++ /dev/null @@ -1,102 +0,0 @@ - - - - Collections — Nexus - - -
- -
-
-

Collections

-

- {data.collections.length} collection{data.collections.length === 1 ? '' : 's'} -

-
- -
- - - {#if filtered.length === 0} -
-
- -
-

- {data.collections.length === 0 ? 'No collections found' : 'No matches'} -

-

- {data.collections.length === 0 - ? 'Collections from your media services will appear here.' - : 'Try adjusting your search.'} -

-
- {:else} -
- {#each filtered as collection, i (collection.id)} - {@const movieCount = (collection.metadata?.movieCount as number) ?? 0} - - -
- {#if collection.poster} - - {:else} -
- -
- {/if} - - - {#if movieCount > 0} -
- {movieCount} movie{movieCount === 1 ? '' : 's'} -
- {/if} -
- - -
-

- {collection.title} -

-
-
- {/each} -
- {/if} -
diff --git a/src/routes/library/catalogs/[id]/+page.server.ts b/src/routes/library/catalogs/[id]/+page.server.ts deleted file mode 100644 index ad48056a..00000000 --- a/src/routes/library/catalogs/[id]/+page.server.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ fetch, params }) => { - const res = await fetch(`/api/library/catalogs/${params.id}`); - if (!res.ok) return { collection: null, movies: [] }; - const data = await res.json(); - return { collection: data, movies: data.items ?? [] }; -}; diff --git a/src/routes/library/catalogs/[id]/+page.svelte b/src/routes/library/catalogs/[id]/+page.svelte deleted file mode 100644 index 47102e0e..00000000 --- a/src/routes/library/catalogs/[id]/+page.svelte +++ /dev/null @@ -1,123 +0,0 @@ - - - - {data.collection?.items?.[0]?.title ?? 'Collection'} — Nexus - - -{#if !data.collection} -
-
- -
-

Collection not found

-

This collection may have been removed or is unavailable.

- - - Back to Collections - -
-{:else} - {@const firstItem = data.movies[0]} - {@const backdrop = firstItem?.backdrop ?? data.movies.find((m: UnifiedMedia) => m.backdrop)?.backdrop} - {@const collectionTitle = (data.collection as Record).title as string | undefined ?? firstItem?.title ?? 'Collection'} - -
- -
- {#if backdrop} - - {/if} -
- {#if !backdrop} -
- {/if} - -
- - - Collections - -

- {collectionTitle} -

-

- {data.movies.length} movie{data.movies.length === 1 ? '' : 's'} - {#if available > 0} - · - {available} available - {/if} - {#if missing > 0} - · - {missing} missing - {/if} -

-
-
- - -
- {#if data.movies.length === 0} -
-

No movies in this collection.

-
- {:else} -
- {#each data.movies as movie (movie.id)} -
- - - {#if movie.status === 'available'} -
- - - -
- {:else if movie.status === 'missing'} -
- + -
- {/if} -
- {/each} -
- {/if} -
-
-{/if} diff --git a/src/routes/library/collections/+page.server.ts b/src/routes/library/collections/+page.server.ts deleted file mode 100644 index 799e3b96..00000000 --- a/src/routes/library/collections/+page.server.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { getUserCollections } from '$lib/server/social'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ locals }) => { - if (!locals.user) return { owned: [], joined: [] }; - - const all = getUserCollections(locals.user.id); - const owned = all.filter((c) => c.userRole === 'owner'); - const joined = all.filter((c) => c.userRole !== 'owner'); - - return { owned, joined }; -}; diff --git a/src/routes/library/collections/+page.svelte b/src/routes/library/collections/+page.svelte deleted file mode 100644 index b3131304..00000000 --- a/src/routes/library/collections/+page.svelte +++ /dev/null @@ -1,213 +0,0 @@ - - - - Collections — Nexus - - -
- -
-
-

My Collections

- -
- - - {#if showCreate} -
- e.key === 'Enter' && createCollection()} - /> - -
-
- {#each ['private', 'friends', 'public'] as v (v)} - {@const VIcon = getVisibilityIcon(v)} - - {/each} -
-
- - -
-
-
- {/if} - - {#if data.owned.length === 0 && !showCreate} -
- -

No collections yet

-

Create one to start organizing your media.

-
- {:else} - - {/if} -
- - - {#if data.joined.length > 0} - - {/if} -
diff --git a/src/routes/library/collections/[id]/+page.server.ts b/src/routes/library/collections/[id]/+page.server.ts deleted file mode 100644 index c01d641c..00000000 --- a/src/routes/library/collections/[id]/+page.server.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { error } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/social'; -import { getCollectionActivity } from '$lib/server/collection-activity'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ params, locals }) => { - if (!locals.user) throw error(401, 'Unauthorized'); - - const collection = getCollection(params.id, locals.user.id); - if (!collection) throw error(404, 'Collection not found'); - - const activity = getCollectionActivity(params.id, { limit: 20 }); - - return { collection, activity }; -}; diff --git a/src/routes/library/collections/[id]/+page.svelte b/src/routes/library/collections/[id]/+page.svelte deleted file mode 100644 index 1d5adc7f..00000000 --- a/src/routes/library/collections/[id]/+page.svelte +++ /dev/null @@ -1,285 +0,0 @@ - - - - {collection.name} — Nexus - - -
- - - - Collections - - -
- -
-
- -
- {#if collection.items.length > 0} -
- {#each collection.items.slice(0, 4) as item (item.id)} - {#if item.mediaPoster} - {item.mediaTitle} - {:else} -
- -
- {/if} - {/each} - {#if collection.items.length < 4 && collection.items.length >= 2} - {#each { length: 4 - Math.min(collection.items.length, 4) } as _, j (j)} -
- {/each} - {/if} -
- {:else} -
- -
- {/if} -
- - - {#if editingName && isOwner} -
- { if (e.key === 'Enter') saveField('name', editName); if (e.key === 'Escape') editingName = false; }} - /> - - -
- {:else} -
-

{collection.name}

- {#if isOwner} - - {/if} -
- {/if} - - - {#if editingDesc && isOwner} -
- -
- - -
-
- {:else if collection.description} -
-

{collection.description}

- {#if isOwner} - - {/if} -
- {:else if isOwner} - - {/if} - - -
- - - {collection.items.length} item{collection.items.length === 1 ? '' : 's'} -
- - -
-
- Members -
-
- {#each collection.members as member (member.userId)} -
-
- {(member.displayName ?? member.username ?? '?').charAt(0).toUpperCase()} -
- {member.displayName ?? member.username} - {member.role} -
- {/each} -
-
- - - {#if activity.length > 0} -
- Activity -
- {#each activity as entry (entry.id)} -
- {entry.displayName ?? entry.username} - {formatAction(entry.action)} - {#if entry.targetTitle} - {entry.targetTitle} - {/if} - {formatActivityTime(entry.createdAt)} -
- {/each} -
-
- {/if} -
-
- - -
- {#if collection.items.length === 0} -
- -

No items yet

-

Add items from any media detail page.

-
- {:else} -
- {#each collection.items as item, i (item.id)} -
- - {#if isEditor} - - {/if} -
- {/each} -
- {/if} -
-
-
diff --git a/src/routes/library/shared/+page.server.ts b/src/routes/library/shared/+page.server.ts deleted file mode 100644 index 0792e65e..00000000 --- a/src/routes/library/shared/+page.server.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { getSharedItems } from '$lib/server/social'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ locals }) => { - if (!locals.user) return { items: [] }; - const items = getSharedItems(locals.user.id, { limit: 50 }); - return { items }; -}; diff --git a/src/routes/library/shared/+page.svelte b/src/routes/library/shared/+page.svelte deleted file mode 100644 index 3f0badba..00000000 --- a/src/routes/library/shared/+page.svelte +++ /dev/null @@ -1,113 +0,0 @@ - - - - Shared With You — Nexus - - -
- {#if data.items.length === 0} -
-
- -
-

Nothing shared yet

-

- When friends share movies, shows, or music with you, they'll appear here. -

-
- {:else} -
- {#each data.items as item, i (item.id)} -
- - - {#if item.mediaPoster} - {item.mediaTitle} - {:else} -
- -
- {/if} -
- - -
-
- - {item.mediaTitle} - -
- - {item.fromDisplayName ?? item.fromUsername} - · - {timeAgo(item.createdAt)} - {#if !item.seen} - - {/if} -
- {#if item.message} -

"{item.message}"

- {/if} -
- - -
- {#if !item.seen} - - {/if} - - - View - -
-
-
- {/each} -
- {/if} -
diff --git a/src/routes/library/watchlist/+page.server.ts b/src/routes/library/watchlist/+page.server.ts deleted file mode 100644 index 1118664f..00000000 --- a/src/routes/library/watchlist/+page.server.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { getUserWatchlist } from '$lib/server/social'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ locals, url }) => { - if (!locals.user) return { items: [], filterType: 'all', sortBy: 'added' }; - - const items = getUserWatchlist(locals.user.id); - const filterType = url.searchParams.get('type') ?? 'all'; - const sortBy = url.searchParams.get('sort') ?? 'added'; - - return { items, filterType, sortBy }; -}; diff --git a/src/routes/library/watchlist/+page.svelte b/src/routes/library/watchlist/+page.svelte deleted file mode 100644 index 62513cab..00000000 --- a/src/routes/library/watchlist/+page.svelte +++ /dev/null @@ -1,151 +0,0 @@ - - - - Watchlist — Nexus - - -
- -
- -
- {#each filterOptions as f (f.id)} - - {/each} -
- - -
- Sort -
- {#each sortOptions as s (s.id)} - - {/each} -
-
-
- - {#if filtered.length === 0} - -
-
- -
-

Your watchlist is empty

-

- Browse your library and bookmark items you want to watch, read, or play later. -

- Browse Library -
- {:else} - -
- {#each filtered as item, i (item.id)} -
- - - -
- {/each} -
- {/if} -
diff --git a/src/routes/live/+page.server.ts b/src/routes/live/+page.server.ts deleted file mode 100644 index 00eb4812..00000000 --- a/src/routes/live/+page.server.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { getAllLiveChannels } from '$lib/server/services'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ locals, fetch }) => { - if (!locals.user) return { channels: [], guide: null }; - - const [channels, guideRes] = await Promise.all([ - getAllLiveChannels(locals.user.id), - fetch('/api/live/guide') - .then((r) => (r.ok ? r.json() : null)) - .catch(() => null) - ]); - - return { channels, guide: guideRes?.guide ?? null }; -}; diff --git a/src/routes/live/+page.svelte b/src/routes/live/+page.svelte deleted file mode 100644 index 13751e43..00000000 --- a/src/routes/live/+page.svelte +++ /dev/null @@ -1,1071 +0,0 @@ - - - - Live TV — Nexus - - -
- -
-
- - - - -

Live TV

- {#if data.channels.length > 0} - {data.channels.length} - {/if} -
-
- {#if data.channels.length > 0} - - {#if hasGuide} -
- - -
- {/if} - {/if} -
-
- - {#if data.channels.length === 0} - -
-
- - - - - - - - - -
-

No live channels found

-

Connect a Jellyfin server with Live TV configured to watch live channels here.

- Manage Services -
- {:else if viewMode === 'grid'} - - - {#if filtered.length === 0 && filter} -

No channels matching "{filter}"

- {/if} - {:else} - -
- -
-
- -
- - {#each filtered as channel (channel.id)} - - {/each} -
- - -
- -
- {#each timeSlots as slot (slot.getTime())} -
- {formatSlotTime(slot)} -
- {/each} - - -
- - -
- {#each filtered as channel (channel.id)} - {@const programs = channelGuide.get(channel.id) ?? []} -
- {#each programs as program (program.startDate + program.title)} - {@const airing = isCurrentlyAiring(program)} - - {/each} - {#if programs.length === 0} -
No guide data
- {/if} -
- {/each} - - -
-
-
- {#if filtered.length === 0 && filter} -

No channels matching "{filter}"

- {/if} - {/if} -
- - -{#if selectedProgram} - -{/if} - - diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts deleted file mode 100644 index 8d761dfc..00000000 --- a/src/routes/login/+page.server.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { fail, redirect } from '@sveltejs/kit'; -import { - COOKIE_NAME, - createSession, - createUser, - getSetting, - getUserByUsername, - upsertUserCredential, - verifyPassword -} from '$lib/server/auth'; -import { getEnabledConfigs, getServiceConfig } from '$lib/server/services'; -import { registry } from '$lib/adapters/registry'; -import { getDb, schema } from '$lib/db'; -import { and, eq } from 'drizzle-orm'; -import type { Actions, PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ locals, url }) => { - // Fresh-install (userCount===0) + logged-in-user redirects both live in - // resolveRedirect (#32). This load only runs when we should render login. - if (locals.user) throw redirect(303, url.searchParams.get('next') || '/'); - const registrationEnabled = getSetting('registration_enabled') === 'true'; - - const authServices = getEnabledConfigs() - .filter((c) => { - const a = registry.get(c.type); - return (c.type === 'jellyfin' || c.type === 'plex') && a?.authenticateUser; - }) - .map((c) => ({ id: c.id, name: c.name, type: c.type })); - - return { registrationEnabled, authServices }; -}; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Find the Nexus userId that has this externalUserId linked to this service */ -function findUserByExternalId(serviceId: string, externalUserId: string): string | null { - const db = getDb(); - const row = db - .select({ userId: schema.userServiceCredentials.userId }) - .from(schema.userServiceCredentials) - .where( - and( - eq(schema.userServiceCredentials.serviceId, serviceId), - eq(schema.userServiceCredentials.externalUserId, externalUserId) - ) - ) - .get(); - return row?.userId ?? null; -} - -/** Generate a unique username by appending a suffix */ -function generateUniqueUsername(base: string, suffix: string): string { - const candidate = `${base}_${suffix}`; - if (!getUserByUsername(candidate)) return candidate; - // If that's taken too, add a number - for (let i = 2; i < 100; i++) { - const attempt = `${base}_${suffix}${i}`; - if (!getUserByUsername(attempt)) return attempt; - } - return `${base}_${Date.now()}`; -} - -/** Create session, set cookie, and determine redirect */ -function finishLogin( - userId: string, - user: { status?: string | null; forcePasswordReset?: boolean | null }, - cookies: Parameters[0]['cookies'], - url: URL -) { - const token = createSession(userId); - cookies.set(COOKIE_NAME, token, { - path: '/', - httpOnly: true, - secure: false, - sameSite: 'lax', - maxAge: 60 * 60 * 24 * 30 - }); - - if (user.status === 'pending') { - throw redirect(303, '/pending-approval'); - } - if (user.forcePasswordReset) { - throw redirect(303, '/reset-password'); - } - const next = url.searchParams.get('next') || '/'; - throw redirect(303, next); -} - -// --------------------------------------------------------------------------- -// Actions -// --------------------------------------------------------------------------- - -export const actions: Actions = { - default: async ({ request, cookies, url }) => { - const data = await request.formData(); - const authType = data.get('authType') as string | null; - - // ── Service authentication (Jellyfin / Plex) ──────────────────────── - if (authType === 'service') { - const serviceId = data.get('serviceId') as string; - const username = (data.get('username') as string)?.trim(); - const password = data.get('password') as string; - const nexusPassword = data.get('nexusPassword') as string | null; - - if (!serviceId || !username || !password) { - return fail(400, { error: 'Service, username, and password are required', authType: 'service', serviceId }); - } - - // Look up service and adapter - const config = getServiceConfig(serviceId); - if (!config) { - return fail(400, { error: 'Service not found', authType: 'service', serviceId }); - } - const adapter = registry.get(config.type); - if (!adapter?.authenticateUser) { - return fail(400, { error: 'This service does not support authentication', authType: 'service', serviceId }); - } - - // Authenticate against the external service - let authResult: { accessToken: string; externalUserId: string; externalUsername: string }; - try { - authResult = await adapter.authenticateUser(config, username, password); - } catch (e) { - const msg = e instanceof Error ? e.message : 'Authentication failed'; - return fail(401, { error: msg, authType: 'service', serviceId, username }); - } - - // 1. Check if a Nexus user already has this externalUserId linked - const existingUserId = findUserByExternalId(serviceId, authResult.externalUserId); - if (existingUserId) { - // Update the credential (token may have changed) - upsertUserCredential(existingUserId, serviceId, { - accessToken: authResult.accessToken, - externalUserId: authResult.externalUserId, - externalUsername: authResult.externalUsername - }); - const db = getDb(); - const user = db - .select() - .from(schema.users) - .where(eq(schema.users.id, existingUserId)) - .get(); - if (!user) { - return fail(500, { error: 'Linked user account not found', authType: 'service', serviceId }); - } - return finishLogin(existingUserId, user, cookies, url); - } - - // 2. Check if a Nexus user with the same username exists - const usernameMatch = getUserByUsername(authResult.externalUsername); - if (usernameMatch) { - // Collision: need to verify they own the Nexus account - if (!nexusPassword) { - return fail(409, { - error: `A Nexus account "${authResult.externalUsername}" already exists. Enter your Nexus password to link it, or choose a different approach.`, - needsNexusPassword: true, - authType: 'service', - serviceId, - username, - collisionUsername: authResult.externalUsername - }); - } - - // Verify the Nexus password - if (!verifyPassword(nexusPassword, usernameMatch.passwordHash)) { - return fail(401, { - error: 'Incorrect Nexus password. Try again or contact an admin.', - needsNexusPassword: true, - authType: 'service', - serviceId, - username, - collisionUsername: authResult.externalUsername - }); - } - - // Password verified — link the credential - upsertUserCredential(usernameMatch.id, serviceId, { - accessToken: authResult.accessToken, - externalUserId: authResult.externalUserId, - externalUsername: authResult.externalUsername - }); - return finishLogin(usernameMatch.id, usernameMatch, cookies, url); - } - - // 3. No collision — create a new Nexus account - const requiresApproval = getSetting('registration_requires_approval') === 'true'; - const status = requiresApproval ? 'pending' : 'active'; - const typeSuffix = config.type === 'jellyfin' ? 'jf' : 'plex'; - // Use the external username, but ensure uniqueness - let newUsername = authResult.externalUsername; - if (getUserByUsername(newUsername)) { - newUsername = generateUniqueUsername(newUsername, typeSuffix); - } - - // Generate a random password for the Nexus account (user authenticates via service) - const randomPassword = crypto.randomUUID(); - const userId = createUser(newUsername, authResult.externalUsername, randomPassword, false, { - authProvider: config.type, - externalId: authResult.externalUserId, - status - }); - - // Link the credential - upsertUserCredential(userId, serviceId, { - accessToken: authResult.accessToken, - externalUserId: authResult.externalUserId, - externalUsername: authResult.externalUsername - }); - - const db2 = getDb(); - const newUser = db2 - .select() - .from(schema.users) - .where(eq(schema.users.id, userId)) - .get(); - return finishLogin(userId, newUser ?? { status }, cookies, url); - } - - // ── Local authentication ──────────────────────────────────────────── - const username = (data.get('username') as string)?.trim(); - const password = data.get('password') as string; - - if (!username || !password) { - return fail(400, { error: 'Username and password are required' }); - } - - const user = getUserByUsername(username); - if (!user || !verifyPassword(password, user.passwordHash)) { - return fail(401, { error: 'Invalid username or password' }); - } - - return finishLogin(user.id, user, cookies, url); - } -}; diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte deleted file mode 100644 index 5dc038b8..00000000 --- a/src/routes/login/+page.svelte +++ /dev/null @@ -1,235 +0,0 @@ - - - - Sign In — Nexus - - -
-
- -
-
- - - -
-
-

Nexus

-

Sign in to your media OS.

-
-
- - {#if data.authServices.length > 0 && !activeServiceId} - -
- {#each data.authServices as svc} - - {/each} -
- - -
-
- or sign in with password -
-
- {/if} - - {#if activeServiceId && activeService} - -
(loading = true)}> - - - -
- - {serviceIcon(activeService.type)} - -
-

Sign in with {activeService.name}

-

- {#if activeService.type === 'plex'} - Paste your Plex token (get one at plex.tv/security) - {:else} - Use your Jellyfin credentials - {/if} -

-
-
- - {#if form?.error && form?.authType === 'service'} -
- {form.error} -
- {/if} - -
- - -
- -
- - -
- - {#if showNexusPassword || form?.needsNexusPassword} -
- A Nexus account with the username "{form?.collisionUsername}" already exists. - Enter your Nexus password to link your {activeService.type === 'plex' ? 'Plex' : 'Jellyfin'} account. -
-
- - -
- {/if} - - - - -
- {:else} - -
(loading = true)}> - {#if form?.error && form?.authType !== 'service'} -
- {form.error} -
- {/if} - -
- - -
-
- - -
- - -
- {/if} - - {#if data.registrationEnabled} -

- Don't have an account? Create one -

- {/if} - -
- -
-
-
diff --git a/src/routes/media/[type]/[id]/+page.server.ts b/src/routes/media/[type]/[id]/+page.server.ts deleted file mode 100644 index 56371004..00000000 --- a/src/routes/media/[type]/[id]/+page.server.ts +++ /dev/null @@ -1,556 +0,0 @@ -import { error } from '@sveltejs/kit'; -import { registry } from '$lib/adapters/registry'; -import { isOverseerrType } from '$lib/adapters/overseerr'; -import { getServiceConfig, getEnabledConfigs } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { getSubtitleStatus, getItemSubtitleHistory } from '$lib/adapters/bazarr'; -import { isPlayableInBrowser } from '$lib/emulator/cores'; -import { emitMediaAction } from '$lib/server/analytics'; -import { resolveTrailerUrl } from '$lib/server/trailers'; -import { getUserWatchlist } from '$lib/server/social'; -import { getUserRating, getMediaRatingStats } from '$lib/server/ratings'; -import { getAutoplayNext } from '$lib/server/user-prefs'; -import type { UnifiedMedia } from '$lib/adapters/types'; -import type { JellyfinSeason } from '$lib/adapters/jellyfin'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ params, url, locals }) => { - const userId = locals.user?.id; - let serviceId = url.searchParams.get('service'); - - // ── Resolve service & fetch item ──────────────────────────────────── - let item: UnifiedMedia | null = null; - let resolvedServiceId = serviceId; - let resolvedServiceType = ''; - - if (serviceId) { - // Explicit service — direct fetch - const config = getServiceConfig(serviceId); - if (!config) throw error(404, 'Service not found'); - - const adapter = registry.get(config.type); - if (!adapter?.getItem) { - // Management services (Sonarr, Radarr, Lidarr) don't serve item detail. - // Try to find the item on a library service or Overseerr/Seerr instead. - const fallbackConfigs = getEnabledConfigs() - .filter((c) => { - const a = registry.get(c.type); - return a?.getItem && (a.isLibrary || a.isSearchable); - }) - .sort((a, b) => { - // Try Overseerr/Seerr first (they understand TMDB/TVDB IDs) - const aPri = registry.get(a.type)?.searchPriority ?? 99; - const bPri = registry.get(b.type)?.searchPriority ?? 99; - return aPri - bPri; - }); - for (const fc of fallbackConfigs) { - const fa = registry.get(fc.type); - if (!fa?.getItem) continue; - const cred = userId && fa.userLinkable - ? getUserCredentialForService(userId, fc.id) ?? undefined - : undefined; - try { - const found = await fa.getItem(fc, params.id, cred); - if (found) { - item = found; - resolvedServiceId = fc.id; - resolvedServiceType = fc.type; - break; - } - } catch { continue; } - } - if (!item) throw error(404, 'Item not found in any library service'); - } - - if (!resolvedServiceType) resolvedServiceType = config.type; - const userCred = userId && adapter?.userLinkable - ? getUserCredentialForService(userId, serviceId) ?? undefined - : undefined; - - // For Overseerr, prefix sourceId with media type so adapter knows which TMDB endpoint - const sourceId = isOverseerrType(config.type) - ? `${params.type === 'show' ? 'tv' : params.type}:${params.id}` - : params.id; - - if (!item && adapter?.getItem) { - item = await adapter.getItem(config, sourceId, userCred); - } - } else { - // No service specified — try all enabled services that support getItem - const configs = getEnabledConfigs(); - for (const config of configs) { - const adapter = registry.get(config.type); - if (!adapter?.getItem) continue; - - const userCred = userId && adapter.userLinkable - ? getUserCredentialForService(userId, config.id) ?? undefined - : undefined; - - try { - const result = await adapter.getItem(config, params.id, userCred); - if (result) { - item = result; - resolvedServiceId = config.id; - resolvedServiceType = config.type; - break; - } - } catch { /* try next service */ } - } - } - - if (!item) throw error(404, 'Item not found'); - - // ── Bazarr subtitle enrichment (non-blocking, 3s timeout) ──────── - const bazarrConfigs = getEnabledConfigs().filter((c) => c.type === 'bazarr'); - if (bazarrConfigs.length > 0 && item) { - const bazarrConfig = bazarrConfigs[0]; - const tmdbId = item.metadata?.tmdbId as string | undefined; - const radarrId = item.metadata?.radarrId as string | undefined; - const sonarrId = item.metadata?.sonarrId as string | undefined; - - if (tmdbId || radarrId || sonarrId) { - try { - const subtitleType = item.type === 'show' || item.type === 'episode' ? 'show' : 'movie'; - const subtitleStatus = await Promise.race([ - getSubtitleStatus(bazarrConfig, tmdbId, { - radarrId, - sonarrId, - type: subtitleType - }), - new Promise((resolve) => setTimeout(() => resolve(null), 1500)) - ]); - - if (subtitleStatus) { - const subtitleHistory = await Promise.race([ - getItemSubtitleHistory(bazarrConfig, tmdbId, { - radarrId, - sonarrId, - type: subtitleType - }), - new Promise<[]>((resolve) => setTimeout(() => resolve([]), 1000)) - ]); - - item.metadata = { - ...item.metadata, - subtitles: { - available: subtitleStatus.available, - missing: subtitleStatus.missing, - wanted: subtitleStatus.wanted, - lastEvent: subtitleHistory[0] ?? undefined - } - }; - } - } catch { /* silent — enrichment is best-effort */ } - } - } - - serviceId = resolvedServiceId!; - - const config = getServiceConfig(serviceId)!; - const adapter = registry.get(config.type)!; - const userCred = userId && adapter.userLinkable - ? getUserCredentialForService(userId, serviceId) ?? undefined - : undefined; - - // ── Quality enrichment (non-blocking, runs in parallel below) ───── - const qualityPromise = (async () => { - const qualityConfigs = getEnabledConfigs().filter((c) => { - const a = registry.get(c.type); - return a?.enrichItem && ['radarr', 'sonarr', 'lidarr'].includes(c.type); - }); - for (const qc of qualityConfigs) { - const qa = registry.get(qc.type); - if (!qa?.enrichItem) continue; - try { - const enriched = await Promise.race([ - qa.enrichItem(qc, item, 'quality'), - new Promise((r) => setTimeout(() => r(null), 2000)) - ]); - if (enriched?.metadata?.quality) return enriched.metadata.quality; - } catch { continue; } - } - return null; - })(); - - // ── Fetch similar items ───────────────────────────────────────────── - let similar: UnifiedMedia[] = []; - try { - if (adapter.getSimilar) { - const sourceIdForSimilar = isOverseerrType(config.type) - ? `${params.type === 'show' ? 'tv' : params.type}:${params.id}` - : params.id; - similar = await adapter.getSimilar(config, sourceIdForSimilar, userCred); - } - } catch { /* silent */ } - - // ── Seasons & episodes ────────────────────────────────────────────── - let seasons: JellyfinSeason[] = []; - let episodes: UnifiedMedia[] = []; - let selectedSeason: number | null = null; - - // Media servers that expose a "seasons + episodes" hierarchy (Jellyfin, Plex). - const SHOW_SERVERS = new Set(['jellyfin', 'plex']); - - if (item.type === 'show' && SHOW_SERVERS.has(resolvedServiceType)) { - const seriesId = item.sourceId; - - // Show page — fetch all seasons, then episodes for the selected season - try { - const subResult = await adapter?.getSubItems?.(config, seriesId, 'season', {}, userCred); - seasons = (subResult?.items ?? []) as unknown as JellyfinSeason[]; - } catch { /* silent */ } - - if (seasons.length > 0) { - const requestedSeason = url.searchParams.get('season'); - if (requestedSeason != null) { - selectedSeason = parseInt(requestedSeason, 10); - } else { - // Default to first season with unwatched episodes, or first season - const unwatched = seasons.find((s) => s.unplayedCount && s.unplayedCount > 0 && s.seasonNumber > 0); - selectedSeason = (unwatched ?? seasons.find((s) => s.seasonNumber > 0) ?? seasons[0]).seasonNumber; - } - - try { - if (adapter.getSeasonEpisodes) { - episodes = await adapter.getSeasonEpisodes(config, seriesId, selectedSeason!, userCred); - } - } catch { /* silent */ } - } - } else if (item.type === 'episode') { - // Episode page — show that season's episodes - try { - const seriesId = item.metadata?.seriesId as string | undefined; - const seasonNumber = item.metadata?.seasonNumber as number | undefined; - if (seriesId && seasonNumber != null && adapter.getSeasonEpisodes) { - episodes = await adapter.getSeasonEpisodes(config, seriesId, seasonNumber, userCred); - selectedSeason = seasonNumber; - - // Also fetch seasons so user can navigate between them - if (SHOW_SERVERS.has(resolvedServiceType)) { - const subResult = await adapter?.getSubItems?.(config, seriesId, 'season', {}, userCred); - seasons = (subResult?.items ?? []) as unknown as JellyfinSeason[]; - } - } - } catch { /* silent */ } - } - - // ── Request context (for Overseerr items) ─────────────────────────── - let canRequest = false; - let overseerrServiceId: string | null = null; - if (isOverseerrType(resolvedServiceType ?? '')) { - canRequest = true; - overseerrServiceId = serviceId; - } - - // ── Analytics: emit detail_view event ───────────────────────────── - if (userId && item) { - const meta: Record = {}; - if (item.metadata?.platform) meta.platform = item.metadata.platform; - if (item.metadata?.platformSlug) meta.platformSlug = item.metadata.platformSlug; - if (item.metadata?.userStatus) meta.userStatus = item.metadata.userStatus; - if (item.metadata?.lastPlayed) meta.lastPlayed = item.metadata.lastPlayed; - if (item.metadata?.retroAchievements) meta.hasRetroAchievements = true; - if (item.metadata?.hltb) meta.hasHltb = true; - - emitMediaAction({ - userId, - serviceId, - serviceType: resolvedServiceType, - actionType: 'detail_view', - mediaId: params.id, - mediaType: item.type, - mediaTitle: item.title, - metadata: Object.keys(meta).length > 0 ? meta : undefined - }); - } - - // ── Video-specific data (Invidious) ──────────────────────────────── - let isSubscribed = false; - let hasLinkedInvidious = false; - let videoNotifyEnabled = false; - - // Local resume fallback for server-hosted media (Jellyfin / Plex). - // Upstream user progress is the source of truth, but if sync is delayed we use - // the latest Nexus activity row so the Resume button still lands near the - // correct position. - const RESUME_SERVERS = new Set(['jellyfin', 'plex']); - if ( - userId && - RESUME_SERVERS.has(resolvedServiceType) && - (item.type === 'movie' || item.type === 'episode') - ) { - try { - const { getDb } = await import('$lib/db'); - const { playSessions } = await import('$lib/db/schema'); - const { eq, and, desc, inArray } = await import('drizzle-orm'); - const db = getDb(); - const mediaIds = Array.from(new Set([params.id, item.sourceId].filter((v): v is string => !!v))); - const record = db.select().from(playSessions) - .where( - and( - eq(playSessions.userId, userId), - eq(playSessions.serviceId, serviceId), - inArray(playSessions.mediaId, mediaIds) - ) - ) - .orderBy(desc(playSessions.updatedAt)) - .get(); - if (record && !record.completed && (record.progress ?? 0) > 0) { - item.progress = Math.max(item.progress ?? 0, record.progress ?? 0); - if (!item.duration && record.positionTicks && (record.progress ?? 0) > 0) { - item.duration = record.positionTicks / 10_000_000 / (record.progress ?? 1); - } - } - } catch { /* silent */ } - } - - if (resolvedServiceType === 'invidious' && userId) { - hasLinkedInvidious = !!userCred?.accessToken; - if (hasLinkedInvidious && userCred && item.metadata?.authorId) { - try { - const { getSubscriptions } = await import('$lib/adapters/invidious'); - const { isChannelNotifyEnabled } = await import('$lib/server/video-notifications'); - const subs = await getSubscriptions(config, userCred); - isSubscribed = subs.some((s: any) => s.authorId === item.metadata?.authorId); - videoNotifyEnabled = isChannelNotifyEnabled(userId, item.metadata.authorId as string); - } catch { /* silent */ } - } - } - - // ── Game-specific data (RomM saves, states, screenshots) ──────────── - let gameSaves: any[] = []; - let gameStates: any[] = []; - let gameScreenshots: any[] = []; - - if (item.type === 'game' && resolvedServiceType === 'romm') { - try { - const enriched = await Promise.all([ - adapter.enrichItem?.(config, { sourceId: params.id } as any, 'saves', userCred), - adapter.enrichItem?.(config, { sourceId: params.id } as any, 'states', userCred), - adapter.enrichItem?.(config, { sourceId: params.id } as any, 'screenshots', userCred) - ]); - gameSaves = (enriched[0]?.metadata?.saves ?? []) as any[]; - gameStates = (enriched[1]?.metadata?.states ?? []) as any[]; - gameScreenshots = (enriched[2]?.metadata?.screenshots ?? []) as any[]; - } catch { /* silent — best-effort enrichment */ } - } - - // ── Game notes (from Nexus DB) ────────────────────────────────────── - let gameNoteContent = ''; - if (item.type === 'game' && userId) { - try { - const { getDb } = await import('$lib/db'); - const { gameNotes } = await import('$lib/db/schema'); - const { eq, and } = await import('drizzle-orm'); - const db = getDb(); - const note = db - .select() - .from(gameNotes) - .where( - and( - eq(gameNotes.userId, userId), - eq(gameNotes.romId, params.id), - eq(gameNotes.serviceId, serviceId) - ) - ) - .get(); - if (note) gameNoteContent = note.content; - } catch { /* silent */ } - } - - // ── Book-specific data (Calibre) ──────────────────────────────────── - let bookRelated: { sameAuthor: UnifiedMedia[]; sameSeries: UnifiedMedia[]; nextInSeries?: UnifiedMedia; prevInSeries?: UnifiedMedia } = { sameAuthor: [], sameSeries: [] }; - let bookFormats: { formats: { name: string; downloadUrl: string }[] } = { formats: [] }; - let bookNotes: any[] = []; - let bookHighlights: any[] = []; - let bookBookmarks: any[] = []; - - if (item.type === 'book' && resolvedServiceType === 'calibre') { - try { - const [relatedEnriched, formatsEnriched] = await Promise.all([ - adapter.enrichItem?.(config, { sourceId: params.id } as any, 'related', userCred), - adapter.enrichItem?.(config, { sourceId: params.id } as any, 'formats', userCred) - ]); - bookRelated = (relatedEnriched?.metadata?.related as typeof bookRelated) ?? bookRelated; - bookFormats = (formatsEnriched?.metadata?.formats as typeof bookFormats) ?? bookFormats; - } catch { /* silent — best-effort enrichment */ } - - // Fetch user annotations from DB - if (userId) { - const { getDb, schema } = await import('$lib/db'); - const { eq, and, desc } = await import('drizzle-orm'); - const db = getDb(); - - try { - [bookNotes, bookHighlights, bookBookmarks] = await Promise.all([ - db.select().from(schema.bookNotes) - .where(and(eq(schema.bookNotes.userId, userId), eq(schema.bookNotes.bookId, params.id), eq(schema.bookNotes.serviceId, serviceId))) - .orderBy(desc(schema.bookNotes.updatedAt)) - .all(), - db.select().from(schema.bookHighlights) - .where(and(eq(schema.bookHighlights.userId, userId), eq(schema.bookHighlights.bookId, params.id), eq(schema.bookHighlights.serviceId, serviceId))) - .orderBy(desc(schema.bookHighlights.createdAt)) - .all(), - db.select().from(schema.bookBookmarks) - .where(and(eq(schema.bookBookmarks.userId, userId), eq(schema.bookBookmarks.bookId, params.id), eq(schema.bookBookmarks.serviceId, serviceId))) - .orderBy(desc(schema.bookBookmarks.createdAt)) - .all() - ]); - } catch { /* silent */ } - } - } - - // ── Invidious stream data ──────────────────────────────────────────── - let videoStreamUrl: string | undefined; - let videoCaptions: { label: string; lang: string; url: string }[] = []; - - if (resolvedServiceType === 'invidious' && item.type === 'video') { - videoStreamUrl = `/api/video/stream/${params.id}`; - - // Load saved progress for resume - if (userId) { - try { - const { getDb } = await import('$lib/db'); - const { playSessions } = await import('$lib/db/schema'); - const { eq, and, desc } = await import('drizzle-orm'); - const db = getDb(); - const record = db.select().from(playSessions) - .where(and( - eq(playSessions.userId, userId), - eq(playSessions.mediaId, params.id), - eq(playSessions.serviceId, serviceId) - )) - .orderBy(desc(playSessions.updatedAt)) - .get(); - const progress = record?.progress ?? 0; - if (record && !record.completed && progress > 0 && progress < 0.9) { - item.progress = progress; - if (record.positionTicks) { - item.duration = record.positionTicks / 10_000_000 / (progress || 1); - } - } - } catch { /* silent */ } - } - - // Map Invidious captions to proxied URLs - const rawCaptions = (item.metadata?.captions as any[]) ?? []; - videoCaptions = rawCaptions.map((c: any) => { - const captionUrl = c.url ?? c.src ?? ''; - return { - label: c.label ?? c.language ?? 'Unknown', - lang: c.language_code ?? c.languageCode ?? c.lang ?? '', - url: `/api/video/stream/${params.id}/captions?url=${encodeURIComponent(captionUrl)}` - }; - }); - } - - // ── Watchlist check ───────────────────────────────────────────────── - const watchlistItems = userId ? getUserWatchlist(userId) : []; - const watchlistEntry = watchlistItems.find( - (f) => f.mediaId === params.id && f.serviceId === serviceId - ); - const inWatchlist = !!watchlistEntry; - const watchlistItemId = watchlistEntry?.id ?? null; - - // ── User rating ──────────────────────────────────────────────────── - const userRating = userId ? getUserRating(userId, params.id, serviceId) : null; - const ratingStats = await getMediaRatingStats(params.id, serviceId); - - // ── Trailer resolution ────────────────────────────────────────────── - const trailerUrl = await resolveTrailerUrl( - params.id, - serviceId, - item.title, - item.year, - (item.metadata?.trailerUrl as string) ?? null, - userId - ); - - // Resolve quality enrichment (was running in parallel) - const quality = await qualityPromise; - if (quality) { - item.metadata = { ...item.metadata, quality }; - } - - // ── Playback preferences (issue #20) ────────────────────────────── - // Resolve a playbackRate for this item from the user's speed rules. - // autoplayNext goes through the canonical helper in $lib/server/user-prefs - // so the player + root layout + settings page cannot disagree on defaults. - let playbackRate = 1; - let autoplayNext = false; - if (userId) { - try { - const { getDb, schema } = await import('$lib/db'); - const { eq } = await import('drizzle-orm'); - const { resolvePlaybackRate } = await import('$lib/server/speed-resolver'); - const db = getDb(); - const rules = db - .select() - .from(schema.playbackSpeedRules) - .where(eq(schema.playbackSpeedRules.userId, userId)) - .all(); - const channelId = (item.metadata?.authorId as string | undefined) ?? undefined; - playbackRate = resolvePlaybackRate(rules, item.type, params.id, channelId); - - autoplayNext = getAutoplayNext(userId); - } catch { /* silent */ } - } - - // ── Post-play data: next item + skip markers (Jellyfin-only this cycle) ── - // Non-Jellyfin sources get null/[] and the player hides the up-next card - // and skip buttons accordingly. See plan §4 and the locked decisions. - let nextItem: import('$lib/adapters/player-markers').NextItemData | null = null; - let skipMarkers: import('$lib/adapters/player-markers').SkipMarkerData[] = []; - if ( - resolvedServiceType === 'jellyfin' && - (item.type === 'episode' || item.type === 'movie') - ) { - try { - if (adapter.getNextItem) { - nextItem = await adapter.getNextItem(config, params.id, userCred); - } - } catch { /* silent */ } - try { - if (adapter.getSkipMarkers) { - skipMarkers = await adapter.getSkipMarkers(config, params.id, userCred); - } - } catch { /* silent */ } - } - - return { - nextItem, - skipMarkers, - playbackPrefs: { playbackRate, autoplayNext }, - item, - serviceType: resolvedServiceType, - serviceId, - similar, - episodes, - seasons, - selectedSeason, - canRequest, - overseerrServiceId, - isAdmin: locals.user?.isAdmin ?? false, - gameSaves, - gameStates, - gameScreenshots, - isSubscribed, - hasLinkedInvidious, - videoNotifyEnabled, - invidiousBaseUrl: resolvedServiceType === 'invidious' ? config.url : undefined, - videoStreamUrl, - videoCaptions, - bookRelated, - bookFormats, - bookNotes, - bookHighlights, - bookBookmarks, - supportsEmulation: item.type === 'game' && isPlayableInBrowser(item.metadata?.platformSlug as string | undefined), - gameNoteContent, - inWatchlist, - watchlistItemId, - userRating, - ratingStats, - trailerUrl - }; -}; diff --git a/src/routes/media/[type]/[id]/+page.svelte b/src/routes/media/[type]/[id]/+page.svelte deleted file mode 100644 index 7823eb72..00000000 --- a/src/routes/media/[type]/[id]/+page.svelte +++ /dev/null @@ -1,3211 +0,0 @@ - - - - {item.title} — Nexus - - -{#if !isVideo} -
- - - - - {#if isPlayable && !isAudioType && (showPlayer || autoplay) && playbackSession} - {#key item.id} - { playbackSession = null; closePlayer(); }} - onqualitychange={handleQualityChange} - onaudiochange={handleAudioChange} - onsubtitlechange={handleSubtitleChange} - nextItem={(data as any).nextItem ?? null} - skipMarkers={(data as any).skipMarkers ?? []} - onplaynext={handlePlayNext} - autoplayNext={(data as any).playbackPrefs?.autoplayNext ?? false} - playbackRate={(data as any).playbackPrefs?.playbackRate ?? 1} - /> - {/key} - {:else if isPlayable && !isAudioType && (showPlayer || autoplay) && !playbackSession} -
-
Loading playback...
-
- {/if} - - - {#if !(isPlayable && !isAudioType && (showPlayer || autoplay))} - - - {#if isPlayable && !isAudioType} - - {/if} - - -
-
- - {#if item.poster && item.type !== 'episode'} -
- {item.title} -
- {/if} - - -
- -
- -
- - {typeLabel[item.type] ?? item.type} - {#if inLibrary} - - - In Library - - {:else if canRequest && isAvailable} - - - Available - - {:else if canRequest && (isRequested || requested)} - Requested - {:else if canRequest} - Not in Library - {/if} -
- - - {#if subtitleLine} -

- {subtitleLine} - {#if seasonNumber != null && episodeNumber != null} - S{String(seasonNumber).padStart(2, '0')}E{String(episodeNumber).padStart(2, '0')} - {/if} -

- {/if} - - - {#if item.poster} - {item.title} - {/if} - - -

{item.title}

- - - {#if episodeTitle && item.type === 'episode' && episodeTitle !== item.title} -

{episodeTitle}

- {/if} - - -
- {#if item.year}{item.year}{/if} - {#if officialRating} - · - {officialRating} - {/if} - {#if item.duration} - · - {formatDuration(item.duration)} - {/if} - {#if item.rating} - · - - {ratingSource} - {item.rating.toFixed(1)} - - {/if} - {#if ratingStats} - · - - Nexus - ★ {ratingStats.avg.toFixed(1)} - ({ratingStats.count}) - - {/if} - {#if endTime()} - · - Ends at {endTime()} - {/if} -
- - - {#if item.metadata?.quality} -
- -
- {/if} - - - {#if item.type === 'episode' && seasonNumber != null && episodeNumber != null} -
- Season {seasonNumber} · Episode {episodeNumber} -
- {/if} - - - {#if taglines.length > 0} -

"{taglines[0]}"

- {/if} -
- - -
- - {#if item.description} - - {/if} - - - {#if criticRating != null || (item.genres && item.genres.length > 0)} -
- {#if criticRating != null} - Critic {criticRating}% - {/if} - {#if item.genres && item.genres.length > 0} - {#each item.genres as genre} - {genre} - {/each} - {/if} -
- {/if} -
- - -
- - {#if isPlayable && isAudioType} -
- {#key item.id} - {#if audioPlaybackSession} - - {:else} -
- Loading... -
- {/if} - {/key} -
- {/if} - - - {#if !showPlayer && item.progress != null && item.progress > 0 && item.progress < 1 && !isAudioType} -
-
-
-
- {formatDuration(Math.round((item.duration ?? 0) * (1 - item.progress)))} remaining -
- {/if} - - - {#if canRequest && item.type === 'show' && seasonCount} -

- {seasonCount} Season{seasonCount !== 1 ? 's' : ''}{#if item.metadata?.episodeCount} · {item.metadata.episodeCount} Episodes{/if} -

- {/if} - - -
-
(ratingHover = 0)} - role="group" - aria-label="Rate this {item.type}" - > - {#each [1, 2, 3, 4, 5] as star} - - {/each} -
- {#if ratingStats} - {ratingStats.avg.toFixed(1)} avg ({ratingStats.count}) - {/if} - {#if ratingCleared} - Rating cleared - {/if} -
- - -
- {#if isBook && item.actionUrl} - - - Read - - {:else if isPlayable && !showPlayer && !isAudioType} - - {:else if item.type === 'show' && nextEpisode} - - - {#if nextEpisode.progress && nextEpisode.progress > 0 && nextEpisode.progress < 0.9} - Resume S{String(nextEpisode.metadata?.seasonNumber ?? selectedSeason ?? '').padStart(2, '0')}E{String(nextEpisode.metadata?.episodeNumber ?? '').padStart(2, '0')} - {:else} - Watch S{String(nextEpisode.metadata?.seasonNumber ?? selectedSeason ?? '').padStart(2, '0')}E{String(nextEpisode.metadata?.episodeNumber ?? '').padStart(2, '0')} - {/if} - - {:else if canRequest} - {#if isAvailable} - - - Available — Watch - - {:else if isRequested || requested} -
- - Requested -
- {:else} - - {/if} - {/if} - - - -
- - {#if requestError} -

{requestError}

- {/if} - - - {#if item.studios && item.studios.length > 0} -

{item.studios.join(' · ')}

- {/if} -
-
- -
-
-
- {/if} - - -
- - - {#if item.type === 'show' || item.type === 'episode' || seasons.length > 0 || episodes.length > 0} -
- - {#if seasons.length > 1} -
- {#each seasons as s} - - {/each} -
- {/if} - - {#if episodes.length > 0} -
-

- {#if selectedSeason != null}Season {selectedSeason}{:else if seasonNumber != null}Season {seasonNumber}{:else}Episodes{/if} - {episodes.length} episodes -

-
- - -
-
- -
- {#each episodes as ep, i} - {@const epNum = ep.metadata?.episodeNumber as number | undefined} - {@const epName = (ep.metadata?.episodeTitle as string) ?? ep.title} - {@const epProg = ep.progress ?? 0} - {@const isCurrent = ep.sourceId === item.sourceId} - {@const isWatched = epProg >= 0.9} - - -
- {#if ep.thumb || ep.backdrop || ep.poster} - - {:else} -
- - - -
- {/if} - - -
{epNum ?? i + 1}
- - - {#if ep.duration} -
{formatDuration(ep.duration)}
- {/if} - - - {#if isCurrent} -
-
- - NOW PLAYING -
-
- {/if} - - - {#if isWatched && !isCurrent} -
- -
- {/if} - - - {#if epProg > 0 && epProg < 1} -
-
-
- {/if} -
- - -
- {epName} - {#if ep.description} - {ep.description} - {/if} -
-
- {/each} -
- {:else if item.type === 'show' || item.type === 'episode'} -
- Episode data is unavailable right now. The title loaded, but season details did not come back from Jellyfin. -
- {/if} -
- {/if} - - - {#if cast.length > 0} -
-
-

Cast & Crew

-
- {#each cast.slice(0, 20) as person} -
-
- {#if person.imageUrl} - {person.name} - {:else} -
- -
- {/if} -
- {person.name} - {person.role} -
-
-
- {/each} -
-
- {/if} - - - - {#if similar.length > 0} -
-
-

More Like This

- -
- {/if} - - - {#if isGame} - -
-
- {#if gamePlatform} - {gamePlatform} - {/if} - - - {#if supportsEmulation} - - - Play in Browser - - {/if} -
-
- - -
- - {#if gameSaves.length > 0 || gameStates.length > 0} - - {/if} - {#if gameScreenshots.length > 0} - - {/if} - - -
- - {#if gameTab === 'overview'} - - {#if gamePlatform || gameFileSize || gameRegions.length > 0} -
-

Game Info

-
- {#if gamePlatform} -
- Platform - {gamePlatform} -
- {/if} - {#if currentGameStatus} -
- Status - {currentGameStatus} -
- {/if} - {#if gameFileSize} -
- File Size - {formatFileSize(gameFileSize)} -
- {/if} - {#if gameRegions.length > 0} -
- Region - {gameRegions.join(', ')} -
- {/if} - {#if gameTags.length > 0} -
- Tags - {gameTags.join(', ')} -
- {/if} -
-
- {/if} - - - {#if gameHltb && (gameHltb.main || gameHltb.extra || gameHltb.completionist)} -
-

How Long to Beat

- -
- {/if} - - - {#if gameRA && gameRA.achievements && gameRA.achievements.length > 0} -
-

- RetroAchievements - {gameRA.achievements.length} achievements -

- -
- {#each gameRA.achievements as ach} - - {/each} -
-
- {/if} - {/if} - - - {#if gameTab === 'saves'} -
- -
- -
- - {#if gameStates.length > 0} -

Save States

-
- {#each gameStates as state} -
-
- {#if state.screenshot_url} - - {:else} -
- -
- {/if} -
-
- {state.file_name} -
- STATE - {formatSaveTime(state.updated_at || state.created_at)} - {#if state.file_size_bytes} - {formatFileSize(state.file_size_bytes)} - {/if} -
-
-
- - -
-
- {/each} -
- {/if} - - {#if gameSaves.length > 0} -

Battery Saves (SRAM)

-
- {#each gameSaves as save} -
-
- {#if save.screenshot_url} - - {:else} -
- -
- {/if} -
-
- {save.file_name} -
- SRAM - {formatSaveTime(save.updated_at || save.created_at)} - {#if save.file_size_bytes} - {formatFileSize(save.file_size_bytes)} - {/if} -
-
-
- - -
-
- {/each} -
- {/if} - - {#if gameSaves.length === 0 && gameStates.length === 0} -
- -

No saves found

-
- {/if} -
- {/if} - - - {#if gameTab === 'screenshots'} -
-

Screenshots

- {#if gameScreenshots.length > 0} -
- {#each gameScreenshots as screenshot} - - {screenshot.file_name} - - {/each} -
- {:else} -
- -

No screenshots

-
- {/if} -
- {/if} - - - {#if gameTab === 'notes'} -
- -
- {/if} - - - {#if gameTab === 'files'} -
-

ROM Information

-
- {#if item.metadata?.fileName} -
- File Name - {item.metadata.fileName} -
- {/if} - {#if gameFileSize} -
- File Size - {formatFileSize(gameFileSize)} -
- {/if} - {#if gameRegions.length > 0} -
- Region - {gameRegions.join(', ')} -
- {/if} - {#if gamePlatform} -
- Platform - {gamePlatform} -
- {/if} - {#if item.metadata?.hash} -
- MD5 Hash -
- {(item.metadata.hash as string).slice(0, 32)} - -
-
- {/if} -
- - - - - Download ROM - -
- {/if} - {/if} - - - {#if isBook} - - {#if bookSeriesName} -
-
- {#if bookRelated.prevInSeries} - - - Previous - - {:else} - - {/if} - - {#if bookSeriesIndex}Book {bookSeriesIndex} in{/if} {bookSeriesName} - - {#if bookRelated.nextInSeries} - - Next - - - {:else} - - {/if} -
-
- {/if} - - - {#if bookFormats.length > 0} -
-
- {#each bookFormats as fmt (fmt.name)} - {@const fmtUpper = fmt.name.toUpperCase()} - {@const isReadable = fmtUpper === 'EPUB' || fmtUpper === 'PDF'} - - {#if fmtUpper === 'EPUB'} - - {:else if fmtUpper === 'PDF'} - - {:else} - - {/if} - {fmtUpper} - - {/each} -
-
- {/if} - - - {#if item.progress != null && item.progress > 0 && item.progress < 1} -
-
-
-
- -
- {/if} - - -
-
- -
-
- - -
-

Book Details

-
- {#if bookAuthor} -
- Author - {bookAuthor} -
- {/if} - {#if bookPublisher} -
- Publisher - {bookPublisher} -
- {/if} - {#if bookLanguage} -
- Language - {bookLanguage} -
- {/if} - {#if item.year} -
- Year - {item.year} -
- {/if} - {#if bookFormats.length > 0} -
- Formats - {bookFormats.map((f: any) => f.name).join(', ')} -
- {/if} - {#if item.rating} -
- Rating - {'★'.repeat(Math.round(item.rating / 2))}{'☆'.repeat(5 - Math.round(item.rating / 2))} -
- {/if} -
-
- - -
-

Notes

-
- - -
- {#if bookNotes.length > 0} -
- {#each bookNotes as note (note.id ?? note.updatedAt)} -
-

{note.content}

- {new Date(note.updatedAt).toLocaleDateString()} -
- {/each} -
- {/if} -
- - - {#if bookHighlights.length > 0 || bookBookmarks.length > 0} -
-

- Your Annotations - {bookHighlights.length + bookBookmarks.length} -

-
- {#each bookHighlights as hl (hl.id)} -
-
- - {hl.chapter ?? 'Highlight'} -
-

"{hl.text}"

- {#if hl.note} -

{hl.note}

- {/if} -
- {/each} - {#each bookBookmarks as b (b.id)} -
- 🔖 - {b.label ?? b.cfi} -
- {/each} -
-
- {/if} - - - {#if bookRelated.sameSeries && bookRelated.sameSeries.length > 0} -
-

- In This Series - {bookRelated.sameSeries.length + 1} books -

-
- {#each bookRelated.sameSeries as book (book.id)} - {@const isCurrent = book.sourceId === item.sourceId} - -
- {#if book.poster} - {book.title} - {:else} -
- -
- {/if} -
-

{book.title}

- {#if book.metadata?.seriesIndex} -

Book {book.metadata.seriesIndex}

- {/if} -
- {/each} -
-
- {/if} - - - {#if bookRelated.sameAuthor && bookRelated.sameAuthor.length > 0} -
-

- More by {bookAuthor || 'This Author'} - {bookRelated.sameAuthor.length} books -

- -
- {/if} - {/if} -
-
-{:else} - -
- - - -
- -
- - {#if videoPlaybackSession && item} - {#key item.sourceId} - - {/key} - {:else} -
- {#if item.backdrop} - - {:else if item.poster} - - {/if} -
- {#if item?.type === 'video' && !videoPlaybackSession} -
Loading player...
- {:else} - -

No stream available

- {/if} -
-
- {/if} - - -
-

{item.title}

-
- {#if videoViewCount} - {formatViews(videoViewCount)} views - {/if} - {#if videoPublishedText} - · {videoPublishedText} - {/if} - {#if videoLikeCount} - · {formatCount(videoLikeCount)} - {/if} -
-
- - -
-
- - {#if showPlaylistMenu} -
- {#if userPlaylists.length === 0} -

No playlists found

- {:else} - {#each userPlaylists as pl (pl.playlistId)} - - {/each} - {/if} -
- {/if} -
-
- - {#if shareTooltip} - Copied! - {/if} -
-
- - - { - const thumbs = item.metadata?.authorThumbnails as any[] | undefined; - if (!thumbs?.length) return ''; - const t = thumbs.find((t: any) => t.width >= 48) ?? thumbs[thumbs.length - 1]; - const url = t?.url ?? ''; - return url.startsWith('//') ? `https:${url}` : url; - })()} - isSubscribed={videoIsSubscribed} - hasLinkedAccount={hasLinkedInvidious} - serviceId={data.serviceId} - notifyEnabled={videoNotifyEnabled} - /> - - - {#if item.description} -
-

- {item.description} -

- {#if item.description.length > 300} - - {/if} -
- {/if} - - - {#if item.genres && item.genres.length > 0} -
- {#each item.genres as genre} - {genre} - {/each} -
- {/if} - - - {#if videoKeywords.length > 0} -
- {#each videoKeywords.slice(0, 15) as keyword} - {keyword} - {/each} -
- {/if} - - - -
- - - -
-
-{/if} - - - - diff --git a/src/routes/movies/+page.server.ts b/src/routes/movies/+page.server.ts deleted file mode 100644 index 9546792d..00000000 --- a/src/routes/movies/+page.server.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { browseLibrary, getConfigsForMediaType, getEnabledConfigs } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { registry } from '$lib/adapters/registry'; -import { withCache } from '$lib/server/cache'; -import type { UnifiedMedia } from '$lib/adapters/types'; -import type { PageServerLoad } from './$types'; - -const PAGE_SIZE = 48; - -export const load: PageServerLoad = async ({ url, locals }) => { - const sortBy = url.searchParams.get('sort') || 'title'; - const q = url.searchParams.get('q')?.trim() ?? ''; - const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10) || 1); - const userId = locals.user?.id; - const hasLibraryService = getConfigsForMediaType('movie').length > 0; - - // Fast: library movies (server-side paginated) — await immediately - const { - items: libraryItems, - total, - pageSize - } = await browseLibrary({ - type: 'movie', - page, - pageSize: PAGE_SIZE, - sortBy, - q, - userId - }); - - // Slow: request-provider popular + trending — stream as deferred promises. - // Any adapter that implements getRequests is treated as a request provider - // (Overseerr, Jellyseerr, …) — no hardcoded `registry.get('overseerr')`. - const requestConfigs = getEnabledConfigs().filter( - (c) => !!registry.get(c.type)?.getRequests - ); - const hasOverseerr = requestConfigs.length > 0; - - function dedup(items: UnifiedMedia[]): UnifiedMedia[] { - const seen = new Set(); - return items.filter((i) => { - if (seen.has(i.sourceId)) return false; - seen.add(i.sourceId); - return true; - }); - } - - async function fetchPopular(): Promise { - if (requestConfigs.length === 0) return []; - return withCache('movies:popular', 120_000, async () => { - const items: UnifiedMedia[] = []; - await Promise.allSettled( - requestConfigs.map(async (c) => { - const adapter = registry.get(c.type); - if (!adapter?.discover) return; - const cred = userId ? getUserCredentialForService(userId, c.id) ?? undefined : undefined; - const result = await adapter.discover(c, { page: 1, category: 'movies' }, cred); - items.push(...result.items); - }) - ); - return dedup(items).slice(0, 20); - }); - } - - async function fetchTrending(): Promise { - if (requestConfigs.length === 0) return []; - return withCache('movies:trending', 120_000, async () => { - const items: UnifiedMedia[] = []; - await Promise.allSettled( - requestConfigs.map(async (c) => { - const adapter = registry.get(c.type); - if (!adapter?.discover) return; - const cred = userId ? getUserCredentialForService(userId, c.id) ?? undefined : undefined; - const result = await adapter.discover(c, { page: 1, category: 'trending' }, cred); - items.push(...result.items); - }) - ); - return dedup(items).filter((i) => i.type === 'movie').slice(0, 20); - }); - } - - return { - libraryItems, - total, - page, - pageSize, - q, - sortBy, - hasLibraryService, - hasOverseerr, - // Streamed — page renders immediately, these fill in when ready - popularMovies: fetchPopular(), - trendingMovies: fetchTrending() - }; -}; diff --git a/src/routes/movies/+page.svelte b/src/routes/movies/+page.svelte deleted file mode 100644 index c5c6fe12..00000000 --- a/src/routes/movies/+page.svelte +++ /dev/null @@ -1,282 +0,0 @@ - - - - Movies — Nexus - - -
- - {#await data.trendingMovies} - {#if data.hasOverseerr} -
- Loading trending movies... -
- {/if} - {:then trendingMovies} - {@const hero = pickHero(trendingMovies)} - {#if hero} - -
-
-
- - {#if hero.year}{hero.year}{/if} - {#if hero.duration} - · - {formatDuration(hero.duration)} - {/if} - {#if hero.rating} - · - - - {hero.rating.toFixed(1)} - - {/if} -
-

{hero.title}

- {#if hero.genres?.length} -
- {#each hero.genres.slice(0, 3) as genre (genre)} - {genre} - {/each} -
- {/if} - {#if hero.description} -

{hero.description}

- {/if} -
- More Info -
-
- -
-
- {/if} - {:catch} - {#if data.hasOverseerr} -
- Trending movie recommendations are unavailable right now. -
- {/if} - {/await} - - -
-
-
-

In Your Library

-

- {data.total} movie{data.total === 1 ? '' : 's'} in your collection -

-
- - -
- Sort by -
- {#each sortOptions as s (s.id)} - - {s.label} - - {/each} -
-
- - - -
- - {#if data.libraryItems.length === 0} -
-
- - - - -
-

No movies found

-

- {data.libraryItems.length === 0 - ? data.hasLibraryService - ? 'Your movie library is empty, still syncing, or your media service is unavailable right now.' - : 'Connect a media service to populate your library.' - : 'Try adjusting your filters.'} -

- {#if data.libraryItems.length === 0 && !data.hasLibraryService} - Connect a Service - {/if} -
- {:else} -
- {#each data.libraryItems as item (item.id)} - -
onCardHover(item)} - onmouseleave={onCardHoverEnd} - > - -
- {/each} -
- - - {#if totalPages > 1} -
- - Page {data.page} of {totalPages} - -
- {/if} - {/if} -
- - - {#await data.popularMovies} - {#if data.hasOverseerr} -
Loading popular movies...
- {/if} - {:then popularMovies} - {#if popularMovies.length > 0} - - {/if} - {:catch} - {#if data.hasOverseerr} -
Popular movies are unavailable right now.
- {/if} - {/await} -
diff --git a/src/routes/music/+layout.svelte b/src/routes/music/+layout.svelte deleted file mode 100644 index a25a28c6..00000000 --- a/src/routes/music/+layout.svelte +++ /dev/null @@ -1,9 +0,0 @@ - - - -{@render children()} diff --git a/src/routes/music/+page.server.ts b/src/routes/music/+page.server.ts deleted file mode 100644 index ce09e896..00000000 --- a/src/routes/music/+page.server.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { getMusicAlbums, getMusicArtists, getRecentlyPlayed, getJellyfinMusicConfigs } from '$lib/server/music'; -import { resolveHistoryPoster } from '$lib/server/history-thumbnails'; - -export const load: PageServerLoad = async ({ locals }) => { - const userId = locals.user?.id; - if (!userId) return { recentlyPlayed: [], newAlbums: [], artists: [], serviceUrls: {} }; - - const configs = getJellyfinMusicConfigs(); - const serviceUrls: Record = {}; - for (const c of configs) serviceUrls[c.id] = c.url; - - const [rawRecentlyPlayed, albumsResult, artistsResult] = await Promise.all([ - getRecentlyPlayed(userId, 20), - getMusicAlbums(userId, { sort: 'added', limit: 20 }), - getMusicArtists(userId, { sort: 'SortName', limit: 20 }) - ]); - - // Resolve per-service poster URLs server-side (matches A10 history pattern) - // so the client doesn't hardcode Jellyfin /Items/.../Primary assumptions. - const recentlyPlayed = rawRecentlyPlayed.map((item) => ({ - ...item, - poster: resolveHistoryPoster({ - serviceId: item.serviceId, - serviceType: item.serviceType, - mediaId: item.mediaId, - mediaType: 'music', - serviceUrl: serviceUrls[item.serviceId] - }) - })); - - return { - recentlyPlayed, - newAlbums: albumsResult.items ?? albumsResult, - artists: artistsResult.items ?? artistsResult, - serviceUrls - }; -}; diff --git a/src/routes/music/+page.svelte b/src/routes/music/+page.svelte deleted file mode 100644 index 3161c9bc..00000000 --- a/src/routes/music/+page.svelte +++ /dev/null @@ -1,201 +0,0 @@ - - - - Music — Nexus - - -
-

{greeting}

- - {#if !hasData} -
-
- - - -
-

Connect a music service to start listening.

- Connect a Service -
- {:else} - - {#if data.recentlyPlayed.length > 0} -
- {#each data.recentlyPlayed.slice(0, 6) as item (item.mediaId ?? item.serviceId + item.timestamp)} - {@const chipArt = item.poster ?? null} - {@const chipLowRes = lowResImageUrl(chipArt)} - {@const chipKey = item.mediaId ?? item.serviceId + item.timestamp} - - {#if chipArt} -
- {#if chipLowRes && !loadedChips[chipKey]} - - {/if} - (loadedChips = { ...loadedChips, [chipKey]: true })} - /> -
- {:else} -
- - - -
- {/if} - {item.mediaTitle ?? 'Unknown'} -
- {/each} -
- {/if} - - - {#if data.newAlbums.length > 0} -
-

New in Your Library

-
- {#each data.newAlbums as album (album.id)} -
- -
- {/each} -
-
- {/if} - - - {#if data.artists.length > 0} -
-

Your Artists

-
- {#each data.artists as artist (artist.id)} -
- -
- {/each} -
-
- {/if} - {/if} -
- - diff --git a/src/routes/music/albums/+page.server.ts b/src/routes/music/albums/+page.server.ts deleted file mode 100644 index c374ae69..00000000 --- a/src/routes/music/albums/+page.server.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { getMusicAlbums } from '$lib/server/music'; - -export const load: PageServerLoad = async ({ url, locals }) => { - const userId = locals.user?.id; - if (!userId) return { albums: [], genres: [], currentGenre: '', currentSort: 'added', search: '' }; - - const genre = url.searchParams.get('genre') ?? ''; - const sort = url.searchParams.get('sort') ?? 'added'; - const search = url.searchParams.get('q') ?? ''; - - const result = await getMusicAlbums(userId, { genre: genre || undefined, sort: sort || 'added', limit: 200 }); - - let albums = result.items ?? result; - if (search) { - const q = search.toLowerCase(); - albums = albums.filter((a: any) => a.title?.toLowerCase().includes(q) || (a.metadata?.artist as string ?? '').toLowerCase().includes(q)); - } - - const genres = [...new Set(albums.flatMap((a: any) => a.genres ?? []))].sort(); - return { albums, genres, currentGenre: genre, currentSort: sort, search }; -}; diff --git a/src/routes/music/albums/+page.svelte b/src/routes/music/albums/+page.svelte deleted file mode 100644 index 316a5005..00000000 --- a/src/routes/music/albums/+page.svelte +++ /dev/null @@ -1,229 +0,0 @@ - - - - Albums — Nexus - - -
-

Albums

- - -
-
- - {#each visibleGenres as genre (genre)} - - {/each} -
- - -
- - -
- {#each sortOptions as opt (opt.value)} - - {/each} -
- - - {#if data.albums.length > 0} -
- {#each data.albums as album (album.id)} - - {/each} -
- {:else} -
-

No albums found

-
- {/if} -
- - diff --git a/src/routes/music/albums/[id]/+page.server.ts b/src/routes/music/albums/[id]/+page.server.ts deleted file mode 100644 index a8094b6a..00000000 --- a/src/routes/music/albums/[id]/+page.server.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { error } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; -import { getMusicAlbumDetail, getMusicAlbums } from '$lib/server/music'; -import { getEnabledConfigs, getConfigsForMediaType } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { registry } from '$lib/adapters/registry'; - -export const load: PageServerLoad = async ({ params, url, locals }) => { - const userId = locals.user?.id; - if (!userId) throw error(401); - - const serviceId = url.searchParams.get('service') ?? ''; - - // Get album tracks - const detail = await getMusicAlbumDetail(userId, params.id, serviceId); - if (!detail) throw error(404, 'Album not found'); - - // Fetch the album item itself (not included in detail) - let album = null; - const config = serviceId - ? getEnabledConfigs().find((c) => c.id === serviceId) - : getConfigsForMediaType('music')[0]; - - if (config) { - const adapter = registry.get(config.type); - const cred = getUserCredentialForService(userId, config.id); - if (adapter?.getItem && cred) { - album = await adapter.getItem(config, params.id, cred); - } - } - - if (!album) throw error(404, 'Album not found'); - - // More by this artist - let moreByArtist: typeof detail.tracks = []; - const artistId = album.metadata?.artistId as string | undefined; - if (artistId) { - try { - const result = await getMusicAlbums(userId, { artistId, limit: 10 }); - const items = result.items ?? []; - moreByArtist = items.filter((a) => a.sourceId !== params.id).slice(0, 6); - } catch { - // ignore - } - } - - return { album, tracks: detail.tracks ?? [], moreByArtist, serviceId }; -}; diff --git a/src/routes/music/albums/[id]/+page.svelte b/src/routes/music/albums/[id]/+page.svelte deleted file mode 100644 index 507db28d..00000000 --- a/src/routes/music/albums/[id]/+page.svelte +++ /dev/null @@ -1,533 +0,0 @@ - - - - {album?.title ?? 'Album'} — Nexus - - -
- -
-
- {#if album?.poster && !imageError} - {album.title} (imageError = true)} - /> - {:else} -
- {/if} -
- -
- Album -

{album?.title ?? 'Unknown Album'}

- {#if artistId} - - {artistName} - - {:else} - {artistName} - {/if} - - {#if album?.year}{album.year} · {/if}{trackCount} song{trackCount !== 1 ? 's' : ''}, {totalMinutes} min - - -
- - - - -
-
-
- - -
-
- # - Title - Artist - Duration -
- - {#each tracks as track, i (track.sourceId ?? track.id)} - {@const playing = isPlaying(track)} - {@const current = isCurrentTrack(track)} - - {/each} -
- - - {#if data.moreByArtist.length > 0} -
-

More by {artistName}

-
- {#each data.moreByArtist as moreAlbum (moreAlbum.sourceId)} - - {/each} -
-
- {/if} -
- - diff --git a/src/routes/music/artists/+page.server.ts b/src/routes/music/artists/+page.server.ts deleted file mode 100644 index 3d31cfec..00000000 --- a/src/routes/music/artists/+page.server.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { getMusicArtists } from '$lib/server/music'; - -export const load: PageServerLoad = async ({ url, locals }) => { - const userId = locals.user?.id; - if (!userId) return { artists: [], currentSort: 'SortName', search: '' }; - - const sort = url.searchParams.get('sort') ?? 'SortName'; - const search = url.searchParams.get('q') ?? ''; - - const result = await getMusicArtists(userId, { sort, limit: 200 }); - - let artists = result.items ?? result; - if (search) { - const q = search.toLowerCase(); - artists = artists.filter((a: any) => (a.name ?? a.title ?? '').toLowerCase().includes(q)); - } - - return { artists, currentSort: sort, search }; -}; diff --git a/src/routes/music/artists/+page.svelte b/src/routes/music/artists/+page.svelte deleted file mode 100644 index b3131110..00000000 --- a/src/routes/music/artists/+page.svelte +++ /dev/null @@ -1,204 +0,0 @@ - - - - Artists — Nexus - - -
-
-
- - - - - -
- -
- {#each sorts as s (s.value)} - - {/each} -
-
- - {#if data.artists.length === 0} -
-
- - - - -
-

No artists found

-
- {:else} -
- {#each data.artists as artist (artist.id)} - - {/each} -
- {/if} -
- - diff --git a/src/routes/music/artists/[id]/+page.server.ts b/src/routes/music/artists/[id]/+page.server.ts deleted file mode 100644 index 2125bf1b..00000000 --- a/src/routes/music/artists/[id]/+page.server.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { error } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; -import { getMusicArtistDetail, getArtistTopSongs } from '$lib/server/music'; - -export const load: PageServerLoad = async ({ params, url, locals }) => { - const userId = locals.user?.id; - if (!userId) throw error(401); - const serviceId = url.searchParams.get('service') ?? ''; - - const [artistDetail, topSongs] = await Promise.all([ - getMusicArtistDetail(userId, params.id, serviceId), - getArtistTopSongs(userId, params.id, serviceId, 5) - ]); - - if (!artistDetail) throw error(404, 'Artist not found'); - - return { - artist: artistDetail.artist, - albums: artistDetail.albums ?? [], - topSongs, - serviceId - }; -}; diff --git a/src/routes/music/artists/[id]/+page.svelte b/src/routes/music/artists/[id]/+page.svelte deleted file mode 100644 index 51b3f7f2..00000000 --- a/src/routes/music/artists/[id]/+page.svelte +++ /dev/null @@ -1,260 +0,0 @@ - - - - {data.artist.name} — Nexus - - -
- -
-
-
-

{data.artist.name}

-

- {albumCount} {albumCount === 1 ? 'album' : 'albums'}{#if trackCount > 0} · {trackCount} tracks{/if} -

-
- - -
-
-
- -
- - {#if topTracks.length > 0} -
-

Top Songs

-
- {#each topTracks as track, i (track.id)} - playAllTopSongs(i)} - /> - {/each} -
-
- {/if} - - - {#if data.albums.length > 0} -
-

Discography

-
- {#each data.albums as album (album.id)} -
- -
- {/each} -
-
- {/if} -
-
- - diff --git a/src/routes/music/playlists/+page.server.ts b/src/routes/music/playlists/+page.server.ts deleted file mode 100644 index a6ff050a..00000000 --- a/src/routes/music/playlists/+page.server.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { getUserPlaylists, getLikedTracks } from '$lib/server/music'; - -export const load: PageServerLoad = async ({ locals }) => { - const userId = locals.user?.id; - if (!userId) return { playlists: [], likedCount: 0 }; - - const [playlists, liked] = await Promise.all([ - getUserPlaylists(userId), - getLikedTracks(userId) - ]); - - return { playlists, likedCount: liked.length }; -}; diff --git a/src/routes/music/playlists/+page.svelte b/src/routes/music/playlists/+page.svelte deleted file mode 100644 index b2b05e68..00000000 --- a/src/routes/music/playlists/+page.svelte +++ /dev/null @@ -1,113 +0,0 @@ - - - - Playlists — Nexus - - -
-

- Your Playlists -

- -
- - goto('/music/liked')} /> - - - {#each data.playlists as playlist (playlist.id)} - goto(`/music/playlists/${playlist.id}`)} - /> - {/each} - - - {#if showCreateInput} -
- -
- - -
-
- {:else} - - {/if} -
-
diff --git a/src/routes/music/search/+page.server.ts b/src/routes/music/search/+page.server.ts deleted file mode 100644 index 2cd120a9..00000000 --- a/src/routes/music/search/+page.server.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { getMusicSongs, getMusicAlbums, getMusicArtists, getUserPlaylists } from '$lib/server/music'; - -export const load: PageServerLoad = async ({ url, locals }) => { - const userId = locals.user?.id; - const query = url.searchParams.get('q') ?? ''; - if (!userId || !query) return { query, songs: [] as any[], albums: [] as any[], artists: [] as any[], playlists: [] as any[] }; - - const [songsResult, albumsResult, artistsResult, allPlaylists] = await Promise.all([ - getMusicSongs(userId, { search: query, limit: 10 }), - getMusicAlbums(userId, { sort: 'added', limit: 50 }), - getMusicArtists(userId, { sort: 'SortName', limit: 50 }), - getUserPlaylists(userId) - ]); - - const songs = songsResult.items ?? []; - - // Client-side filter albums and artists by query (since adapters may not support search) - const q = query.toLowerCase(); - const albums = (albumsResult.items ?? albumsResult) - .filter( - (a: any) => - a.title?.toLowerCase().includes(q) || - ((a.metadata?.artist as string) ?? '').toLowerCase().includes(q) - ) - .slice(0, 10); - - const artists = (artistsResult.items ?? artistsResult) - .filter((a: any) => (a.name ?? a.title ?? '').toLowerCase().includes(q)) - .slice(0, 10); - - const playlists = allPlaylists.filter((p: any) => p.name.toLowerCase().includes(q)); - - return { query, songs, albums, artists, playlists }; -}; diff --git a/src/routes/music/search/+page.svelte b/src/routes/music/search/+page.svelte deleted file mode 100644 index 64e793f3..00000000 --- a/src/routes/music/search/+page.svelte +++ /dev/null @@ -1,416 +0,0 @@ - - - - Search Music — Nexus - - -
- - - - {#if data.query && hasResults} - - {#if data.songs.length > 0} -
-
-

Songs

- {data.songs.length} track{data.songs.length !== 1 ? 's' : ''} - {#if data.songs.length >= 4} - See all - {/if} -
- - -
- # - Title - Album - Duration -
- -
- {#each data.songs.slice(0, 4) as song, i (song.id)} - {@const track = tracks[i]} - {@const isCurrentTrack = musicPlayer.currentTrack?.id === track.id} - playSong(i)} - /> - {/each} -
-
- {/if} - - - {#if data.albums.length > 0} -
-
-

Albums

- {#if data.albums.length >= 4} - See all - {/if} -
-
- {#each data.albums.slice(0, 4) as album (album.id)} -
- -
- {/each} -
-
- {/if} - - - {#if data.artists.length > 0} -
-
-

Artists

- {#if data.artists.length >= 4} - See all - {/if} -
-
- {#each data.artists.slice(0, 4) as artist (artist.id)} -
- -
- {/each} -
-
- {/if} - - - {#if data.playlists.length > 0} -
-
-

Playlists

-
- -
- {/if} - {:else if data.query && !hasResults} -
-

No results for "{data.query}"

-
- {:else} -
-
- - - - -
-

Search your music library

-
- {/if} -
- - diff --git a/src/routes/music/songs/+page.server.ts b/src/routes/music/songs/+page.server.ts deleted file mode 100644 index abff57a6..00000000 --- a/src/routes/music/songs/+page.server.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { getMusicSongs } from '$lib/server/music'; -import { getLikedTracks } from '$lib/server/music'; - -export const load: PageServerLoad = async ({ url, locals }) => { - const userId = locals.user?.id; - if (!userId) return { songs: [], likedIds: [], currentSort: 'SortName', search: '' }; - - const sort = url.searchParams.get('sort') ?? 'SortName'; - const search = url.searchParams.get('q') ?? ''; - - const [result, liked] = await Promise.all([ - getMusicSongs(userId, { sort, limit: 200, search: search || undefined }), - getLikedTracks(userId) - ]); - - const likedIds = liked.map((l) => `${l.trackId}::${l.serviceId}`); - - return { songs: result.items, likedIds, currentSort: sort, search }; -}; diff --git a/src/routes/music/songs/+page.svelte b/src/routes/music/songs/+page.svelte deleted file mode 100644 index 8d367888..00000000 --- a/src/routes/music/songs/+page.svelte +++ /dev/null @@ -1,319 +0,0 @@ - - - - Songs — Nexus - - -
- -
-
- - -
- -
- - - -
-
- - -
- # - Title - Album - Duration -
- - - {#if filteredSongs.length === 0} -
- {#if filter === 'liked'} -

No liked songs yet. Tap the heart on any track to save it here.

- {:else if data.search} -

No songs matching "{data.search}".

- {:else} -

No songs found in your library.

- {/if} -
- {:else} -
- {#each filteredSongs as song, i (song.id)} - {@const track = tracks[i]} - {@const isCurrentTrack = musicPlayer.currentTrack?.id === track.id} - playSong(i)} - onliketoggle={() => handleLikeToggle(song)} - /> - {/each} -
- {/if} -
- - diff --git a/src/routes/music/wanted/+page.server.ts b/src/routes/music/wanted/+page.server.ts deleted file mode 100644 index 1a6e400c..00000000 --- a/src/routes/music/wanted/+page.server.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { getMusicWanted, getMusicQueue } from '$lib/server/music'; - -export const load: PageServerLoad = async ({ locals }) => { - const userId = locals.user?.id; - if (!userId) return { wanted: { items: [], total: 0 }, queue: [] }; - - const [wanted, queue] = await Promise.all([ - getMusicWanted(userId), - getMusicQueue() - ]); - - return { wanted: wanted ?? { items: [], total: 0 }, queue: queue ?? [] }; -}; diff --git a/src/routes/music/wanted/+page.svelte b/src/routes/music/wanted/+page.svelte deleted file mode 100644 index 9166bbd0..00000000 --- a/src/routes/music/wanted/+page.svelte +++ /dev/null @@ -1,226 +0,0 @@ - - - - Wanted — Nexus - - -
- -
-

Wanted Albums

- - {#if data.wanted.items.length === 0} -
- - - - -

No wanted albums

-
- {:else} -
- {#each data.wanted.items as item (item.albumTitle + item.artistName)} -
-
- - - - -
-
- {item.albumTitle} - {item.artistName} -
- Missing -
- {/each} -
- - {#if data.wanted.total > data.wanted.items.length} -

Showing {data.wanted.items.length} of {data.wanted.total} wanted albums

- {/if} - {/if} -
- - -
-

Download Queue

- - {#if data.queue.length === 0} -
- - - - - -

Download queue is empty

-
- {:else} -
- {#each data.queue as item (item.albumTitle + item.artistName)} -
-
- - - - -
-
- {item.albumTitle} - {item.artistName} -
-
- - {item.status === 'downloading' ? 'Downloading' : 'Queued'} - - {#if item.progress > 0} -
-
-
- {/if} -
-
- {/each} -
- {/if} -
-
- - diff --git a/src/routes/pending-approval/+page.server.ts b/src/routes/pending-approval/+page.server.ts deleted file mode 100644 index c28cd63e..00000000 --- a/src/routes/pending-approval/+page.server.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * /pending-approval — lifecycle gates (no-user → /login, approved user → /) - * live in resolveRedirect (#32). The route itself has no server-side data - * to load; the page renders a static "waiting for approval" card that - * points to /api/auth/logout. - * - * Kept as a module (vs. deleted) so the route directory structure stays - * explicit and so any future page-specific data loads have an obvious home. - */ -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async () => { - return {}; -}; diff --git a/src/routes/pending-approval/+page.svelte b/src/routes/pending-approval/+page.svelte deleted file mode 100644 index 62da398a..00000000 --- a/src/routes/pending-approval/+page.svelte +++ /dev/null @@ -1,37 +0,0 @@ - - - - Pending Approval — Nexus - - -
-
-
-
- - - -
-
- -
-
- - - - -
-

Pending Approval

-

- Your account is waiting for admin approval. Check back later. -

- Sign Out -
- -
- -
-
-
diff --git a/src/routes/person/[id]/+page.server.ts b/src/routes/person/[id]/+page.server.ts deleted file mode 100644 index abd25a86..00000000 --- a/src/routes/person/[id]/+page.server.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ fetch, params }) => { - const [personRes, creditsRes] = await Promise.all([ - fetch(`/api/person/${params.id}`), - fetch(`/api/person/${params.id}/credits`) - ]); - const person = personRes.ok ? await personRes.json() : null; - const credits = creditsRes.ok ? await creditsRes.json() : null; - return { person, credits }; -}; diff --git a/src/routes/person/[id]/+page.svelte b/src/routes/person/[id]/+page.svelte deleted file mode 100644 index a327eae0..00000000 --- a/src/routes/person/[id]/+page.svelte +++ /dev/null @@ -1,304 +0,0 @@ - - - - {person?.name ?? 'Person'} — Nexus - - -{#if !person} -
-
- -
-

Person not found

-

This person could not be loaded.

-
-{:else} -
- -
- - -
- -
- {#if profileUrl} - {person.name} - {:else} -
- -
- {/if} -
- - -
-

{person.name}

- - {#if person.known_for_department} - - {person.known_for_department} - - {/if} - -
- {#if birthday} - Born {birthday}{age !== null ? ` (age ${age})` : ''} - {/if} - {#if person.place_of_birth} - {person.place_of_birth} - {/if} - {#if person.deathday} - Died {new Date(person.deathday).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })} - {/if} -
- - {#if person.biography} -

- {person.biography} -

- {/if} -
-
-
- - - {#if departments.length > 0} -
- {#each departments as group (group.department)} -
-
-

{group.department}

- - {group.credits.length} credit{group.credits.length === 1 ? '' : 's'} - -
- -
- {#each group.credits as credit (credit.id)} - {@const poster = creditPoster(credit)} - {@const year = creditYear(credit)} - {@const role = creditRole(credit)} - {@const type = creditType(credit)} - - -
- {#if poster} - - {:else} -
- - - - -
- {/if} - - - {#if isAvailable(credit)} -
- - - -
- {:else if isRequestable(credit)} -
- + -
- {/if} -
- - -
-

- {creditTitle(credit)} -

- {#if role} -

- {role} -

- {/if} - {#if year} -

{year}

- {/if} -
-
- {/each} -
-
- {/each} -
- {:else} -
-

No filmography data available.

-
- {/if} -
-{/if} diff --git a/src/routes/play/[id]/+page.server.ts b/src/routes/play/[id]/+page.server.ts deleted file mode 100644 index 20adfeef..00000000 --- a/src/routes/play/[id]/+page.server.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { error } from '@sveltejs/kit'; -import { getServiceConfig, getConfigsForMediaType } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { registry } from '$lib/adapters/registry'; -import { isPlayableInBrowser } from '$lib/emulator/cores'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ params, url, locals }) => { - if (!locals.user) throw error(401); - - let serviceId = url.searchParams.get('serviceId'); - - if (!serviceId) { - const rommConfigs = getConfigsForMediaType('game'); - if (rommConfigs.length > 0) serviceId = rommConfigs[0].id; - } - if (!serviceId) throw error(400, 'serviceId required'); - - const config = getServiceConfig(serviceId); - if (!config || config.type !== 'romm') throw error(404, 'RomM service not found'); - - const adapter = registry.get('romm'); - if (!adapter?.getItem) throw error(501); - - const userCred = getUserCredentialForService(locals.user.id, serviceId) ?? undefined; - const item = await adapter.getItem(config, params.id, userCred); - if (!item) throw error(404, 'Game not found'); - - const platformSlug = item.metadata?.platformSlug as string | undefined; - if (!platformSlug || !isPlayableInBrowser(platformSlug)) { - throw error(400, `Platform "${platformSlug ?? 'unknown'}" is not supported for in-browser emulation`); - } - - // Fetch saves/states for the cloud save modal - const [savesEnriched, statesEnriched] = await Promise.all([ - adapter.enrichItem?.(config, { sourceId: params.id } as any, 'saves', userCred), - adapter.enrichItem?.(config, { sourceId: params.id } as any, 'states', userCred) - ]); - const saves = (savesEnriched?.metadata?.saves ?? []) as any[]; - const states = (statesEnriched?.metadata?.states ?? []) as any[]; - - // Proxy screenshot URLs through Nexus image proxy - const proxyScreenshot = (url?: string) => { - if (!url) return undefined; - try { - const path = new URL(url).pathname; - return `/api/media/image?service=${serviceId}&path=${encodeURIComponent(path)}`; - } catch { return undefined; } - }; - - return { - item, - serviceId, - saves: saves.map(s => ({ ...s, screenshot_url: proxyScreenshot(s.screenshot_url) })), - states: states.map(s => ({ ...s, screenshot_url: proxyScreenshot(s.screenshot_url) })) - }; -}; diff --git a/src/routes/play/[id]/+page.svelte b/src/routes/play/[id]/+page.svelte deleted file mode 100644 index 51230eca..00000000 --- a/src/routes/play/[id]/+page.svelte +++ /dev/null @@ -1,1708 +0,0 @@ - - - - {item.title} - Play | Nexus - - -
-
- -
- {#if item.poster} - - {/if} -
-

{item.title}

- {item.metadata?.platform ?? ''} -
-
-
- {#if gameReady} - {playTimeFormatted} - {/if} - - - - - -
-
- -
- -
-
- - -{#if toastMessage} -
-
- {#if toastType === 'success'} - - {:else if toastType === 'error'} - - {:else} - - {/if} -
- {toastMessage} - {#if toastType === 'undo' && undoEntry} - - {/if} -
-{/if} - - -{#if showResumeBanner && resumeStateId} -
-
- - - - - - Continue where you left off? - - -
-
-{/if} - - -{#if showShortcuts} -
{ showShortcuts = false; }} onkeydown={(e) => { if (e.key === 'Escape') showShortcuts = false; }} role="button" tabindex="-1" aria-label="Close shortcuts"> - -
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}> -

Keyboard Shortcuts

-
- F2Quick Save - F4Quick Load (latest) - F6Cloud Storage - F11Fullscreen - ?Toggle this panel - EscDismiss banners -
-

Emulator controls are configured in the emulator menu

-
-
-{/if} - - -{#if zoomedUrl} -
{ zoomedUrl = null; }} onkeydown={(e) => { if (e.key === 'Escape') zoomedUrl = null; }} role="button" tabindex="-1" aria-label="Close zoom"> - Screenshot -
-{/if} - - -{#if showModal} -
{ if (e.key === 'Escape') closeModal(); }} role="button" tabindex="-1" aria-label="Close save manager"> - -
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}> - -
-
-
- - - - - -
-
-

Cloud Storage

-

{stateList.length + saveList.length} files synced

-
-
- -
- - -
- - -
- - -
- {#if activeList.length === 0} -
-
- {#if activeTab === 'states'} - - - - - - {:else} - - - - - - {/if} -
-

- {activeTab === 'states' - ? 'No save states yet' - : 'No SRAM saves yet'} -

-

- {activeTab === 'states' - ? 'Use the emulator menu to create a save state' - : 'In-game saves sync automatically'} -

-
- {:else} -
- {#each activeList as entry, i (entry.id)} - {@const type = activeTab === 'states' ? 'state' as const : 'save' as const} - {@const pinned = isPinned(type, entry.id)} - {@const label = getLabel(type, entry)} -
- - -
{ if (entry.screenshot_url) zoomedUrl = entry.screenshot_url; }} onkeydown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && entry.screenshot_url) zoomedUrl = entry.screenshot_url; }} role="button" tabindex="0" class:sm__card-thumb--clickable={!!entry.screenshot_url}> - {#if entry.screenshot_url} - - {:else} -
- - - - - -
- {/if} - {#if pinned} -
- - - -
- {/if} - {#if loadingId === entry.id} -
-
-
- {/if} -
- - -
-
- {relativeTime(entry.updated_at || entry.created_at)} - {#if entry.slot} - Slot {entry.slot} - {/if} -
- {#if label} - {label} - {/if} - {entry.file_name} -
- {formatBytes(entry.file_size_bytes)} - {#if entry.emulator} - - {entry.emulator} - {/if} -
-
- - -
- - - - -
-
- {/each} -
- {/if} -
- - - {#if confirmDeleteEntry} -
-
-

- Delete this {confirmDeleteType}? -

-

{confirmDeleteEntry.file_name}

-
- - -
-
-
- {/if} - - - {#if renamingEntry} -
-
-

Rename

- - { if (e.key === 'Enter') submitRename(); if (e.key === 'Escape') cancelRename(); }} - autofocus - /> -
- - -
-
-
- {/if} -
-
-{/if} - - diff --git a/src/routes/register/+page.server.ts b/src/routes/register/+page.server.ts deleted file mode 100644 index d269f439..00000000 --- a/src/routes/register/+page.server.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { fail, redirect } from '@sveltejs/kit'; -import { - COOKIE_NAME, - createSession, - createUser, - getSetting, - getUserByUsername, - upsertUserCredential -} from '$lib/server/auth'; -import { getEnabledConfigs, getServiceConfig } from '$lib/server/services'; -import { registry } from '$lib/adapters/registry'; -import { getDb, schema } from '$lib/db'; -import { and, eq } from 'drizzle-orm'; -import type { Actions, PageServerLoad } from './$types'; - -export const load: PageServerLoad = async () => { - // Lifecycle gates (registration-disabled → /login, already-logged-in → /) - // live in resolveRedirect (#32). - const authServices = getEnabledConfigs() - .filter((c) => { - const a = registry.get(c.type); - return (c.type === 'jellyfin' || c.type === 'plex') && a?.authenticateUser; - }) - .map((c) => ({ id: c.id, name: c.name, type: c.type })); - - return { authServices }; -}; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Find the Nexus userId that has this externalUserId linked to this service */ -function findUserByExternalId(serviceId: string, externalUserId: string): string | null { - const db = getDb(); - const row = db - .select({ userId: schema.userServiceCredentials.userId }) - .from(schema.userServiceCredentials) - .where( - and( - eq(schema.userServiceCredentials.serviceId, serviceId), - eq(schema.userServiceCredentials.externalUserId, externalUserId) - ) - ) - .get(); - return row?.userId ?? null; -} - -/** Generate a unique username by appending a suffix */ -function generateUniqueUsername(base: string, suffix: string): string { - const candidate = `${base}_${suffix}`; - if (!getUserByUsername(candidate)) return candidate; - for (let i = 2; i < 100; i++) { - const attempt = `${base}_${suffix}${i}`; - if (!getUserByUsername(attempt)) return attempt; - } - return `${base}_${Date.now()}`; -} - -// --------------------------------------------------------------------------- -// Actions -// --------------------------------------------------------------------------- - -export const actions: Actions = { - default: async ({ request, cookies }) => { - if (getSetting('registration_enabled') !== 'true') { - return fail(403, { error: 'Registration is disabled' }); - } - - const data = await request.formData(); - const authType = data.get('authType') as string | null; - - // ── Service authentication (Jellyfin / Plex) ──────────────────────── - if (authType === 'service') { - const serviceId = data.get('serviceId') as string; - const username = (data.get('username') as string)?.trim(); - const password = data.get('password') as string; - - if (!serviceId || !username || !password) { - return fail(400, { error: 'Service, username, and password are required', authType: 'service', serviceId }); - } - - // Look up service and adapter - const config = getServiceConfig(serviceId); - if (!config) { - return fail(400, { error: 'Service not found', authType: 'service', serviceId }); - } - const adapter = registry.get(config.type); - if (!adapter?.authenticateUser) { - return fail(400, { error: 'This service does not support authentication', authType: 'service', serviceId }); - } - - // Authenticate against the external service - let authResult: { accessToken: string; externalUserId: string; externalUsername: string }; - try { - authResult = await adapter.authenticateUser(config, username, password); - } catch (e) { - const msg = e instanceof Error ? e.message : 'Authentication failed'; - return fail(401, { error: msg, authType: 'service', serviceId, username }); - } - - // If a Nexus user already has this externalUserId linked, redirect to login - const existingUserId = findUserByExternalId(serviceId, authResult.externalUserId); - if (existingUserId) { - throw redirect(303, '/login?message=account-exists'); - } - - // If a Nexus user with the same username exists, redirect to login - if (getUserByUsername(authResult.externalUsername)) { - throw redirect(303, '/login?message=account-exists'); - } - - // Create a new Nexus account - const requiresApproval = getSetting('registration_requires_approval') === 'true'; - const status = requiresApproval ? 'pending' : 'active'; - const typeSuffix = config.type === 'jellyfin' ? 'jf' : 'plex'; - let newUsername = authResult.externalUsername; - if (getUserByUsername(newUsername)) { - newUsername = generateUniqueUsername(newUsername, typeSuffix); - } - - const randomPassword = crypto.randomUUID(); - const userId = createUser(newUsername, authResult.externalUsername, randomPassword, false, { - authProvider: config.type, - externalId: authResult.externalUserId, - status - }); - - // Link the credential - upsertUserCredential(userId, serviceId, { - accessToken: authResult.accessToken, - externalUserId: authResult.externalUserId, - externalUsername: authResult.externalUsername - }); - - const token = createSession(userId); - cookies.set(COOKIE_NAME, token, { - path: '/', - httpOnly: true, - sameSite: 'lax', - maxAge: 60 * 60 * 24 * 30 - }); - - if (requiresApproval) { - throw redirect(303, '/pending-approval'); - } - throw redirect(303, '/'); - } - - // ── Local registration ────────────────────────────────────────────── - const username = (data.get('username') as string)?.trim(); - const displayName = (data.get('displayName') as string)?.trim(); - const password = data.get('password') as string; - const confirm = data.get('confirm') as string; - - if (!username || !displayName || !password) { - return fail(400, { error: 'All fields are required' }); - } - if (password.length < 6) { - return fail(400, { error: 'Password must be at least 6 characters' }); - } - if (password !== confirm) { - return fail(400, { error: 'Passwords do not match' }); - } - - const requiresApproval = getSetting('registration_requires_approval') === 'true'; - const status = requiresApproval ? 'pending' : 'active'; - - try { - const userId = createUser(username, displayName, password, false, { status }); - const token = createSession(userId); - cookies.set(COOKIE_NAME, token, { - path: '/', - httpOnly: true, - sameSite: 'lax', - maxAge: 60 * 60 * 24 * 30 - }); - - if (requiresApproval) { - throw redirect(303, '/pending-approval'); - } - - throw redirect(303, '/'); - } catch (e) { - if (e && typeof e === 'object' && 'status' in e) throw e; - const msg = String(e); - if (msg.includes('UNIQUE')) { - return fail(400, { error: 'Username already taken' }); - } - return fail(500, { error: 'Failed to create account' }); - } - } -}; diff --git a/src/routes/register/+page.svelte b/src/routes/register/+page.svelte deleted file mode 100644 index b6e4dc99..00000000 --- a/src/routes/register/+page.svelte +++ /dev/null @@ -1,211 +0,0 @@ - - - - Create Account — Nexus - - -
-
-
-
- - - -
-
-

Create Account

-

Join Nexus — your media OS.

-
-
- - {#if data.authServices.length > 0 && !activeServiceId} - -
- {#each data.authServices as svc} - - {/each} -
- - -
-
- or create with password -
-
- {/if} - - {#if activeServiceId && activeService} - -
(loading = true)}> - - - -
- - {serviceIcon(activeService.type)} - -
-

Sign up with {activeService.name}

-

- {#if activeService.type === 'plex'} - Paste your Plex token (get one at plex.tv/security) - {:else} - Use your Jellyfin credentials - {/if} -

-
-
- - {#if form?.error && form?.authType === 'service'} -
- {form.error} -
- {/if} - -
- - -
- -
- - -
- - - - -
- {:else} - -
(loading = true)}> - {#if form?.error && form?.authType !== 'service'} -
- {form.error} -
- {/if} - -
- - -
-
- - -
-
- - -
-
- - -
- - -
- {/if} - -

- Already have an account? Sign in -

- -
- -
-
-
diff --git a/src/routes/requests/+page.server.ts b/src/routes/requests/+page.server.ts deleted file mode 100644 index a8fc0fdb..00000000 --- a/src/routes/requests/+page.server.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import { registry } from '$lib/adapters/registry'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { getEnabledConfigs } from '$lib/server/services'; -import { withCache } from '$lib/server/cache'; -import type { NexusRequest, UnifiedMedia } from '$lib/adapters/types'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ locals }) => { - if (!locals.user) throw redirect(303, '/login?next=/requests'); - - const isAdmin = locals.user.isAdmin; - const userId = locals.user.id; - - const overseerrConfigs = getEnabledConfigs().filter((c) => { - const adapter = registry.get(c.type); - return !!adapter?.getRequests; - }); - const hasOverseerr = overseerrConfigs.length > 0; - - let hasLinkedOverseerr = false; - - // Check for linked credential (sync — no cache needed) - for (const config of overseerrConfigs) { - if (getUserCredentialForService(userId, config.id)) { - hasLinkedOverseerr = true; - break; - } - } - - // Fast: user's own requests + admin all requests (small API calls, cached) - const [myRequests, allRequests] = await Promise.all([ - hasLinkedOverseerr - ? withCache(`requests:user:${userId}`, 30_000, async () => { - const reqs: NexusRequest[] = []; - await Promise.allSettled( - overseerrConfigs.map(async (config) => { - const adapter = registry.get('overseerr'); - if (!adapter?.getRequests) return; - const userCred = getUserCredentialForService(userId, config.id) ?? undefined; - if (userCred) { - const r = await adapter.getRequests(config, { filter: 'all', take: 100 }, userCred); - reqs.push(...r); - } - }) - ); - return reqs; - }) - : Promise.resolve([] as NexusRequest[]), - - isAdmin - ? withCache('requests:admin-all', 30_000, async () => { - const all: NexusRequest[] = []; - await Promise.allSettled( - overseerrConfigs.map(async (config) => { - const adapter = registry.get('overseerr'); - if (!adapter?.getRequests) return; - const r = await adapter.getRequests(config, { filter: 'all', take: 100 }); - all.push(...r); - }) - ); - return all; - }) - : Promise.resolve([] as NexusRequest[]) - ]); - - // Slow: discover page — streamed, doesn't block navigation - async function fetchDiscover() { - return withCache('requests-page:discover', 120_000, async () => { - const items: UnifiedMedia[] = []; - let hasMore = false; - await Promise.allSettled( - overseerrConfigs.map(async (config) => { - const adapter = registry.get('overseerr'); - if (!adapter?.discover) return; - const cred = userId ? getUserCredentialForService(userId, config.id) ?? undefined : undefined; - const result = await adapter.discover(config, { page: 1 }, cred); - items.push(...result.items); - if (result.hasMore) hasMore = true; - }) - ); - const seen = new Set(); - return { - items: items.filter((i) => { - if (seen.has(i.sourceId)) return false; - seen.add(i.sourceId); - return true; - }), - hasMore - }; - }); - } - - const byDate = (a: NexusRequest, b: NexusRequest) => - new Date(b.requestedAt).getTime() - new Date(a.requestedAt).getTime(); - - return { - myRequests: myRequests.sort(byDate), - allRequests: allRequests.sort(byDate), - initialDiscover: fetchDiscover(), - hasLinkedOverseerr, - isAdmin, - hasOverseerr - }; -}; diff --git a/src/routes/requests/+page.svelte b/src/routes/requests/+page.svelte deleted file mode 100644 index 29283188..00000000 --- a/src/routes/requests/+page.svelte +++ /dev/null @@ -1,1179 +0,0 @@ - - - - Requests — Nexus - - -{#if !data.hasOverseerr} -
-
- -
-

No request service connected

-

Connect Overseerr in Settings to allow users to request movies and shows.

- Open Settings -
-{:else} - - -
-
- - - - - {#if data.isAdmin} - - {:else} - - {/if} -
- - - {#if activeTab === 'discover'} -
- -
-
- {#if searching} - - {:else} - - {/if} -
- - {#if searchQuery} - - {/if} -
- - -
- - {#each [ - { key: 'all', label: 'All' }, - { key: 'movie', label: 'Movies' }, - { key: 'show', label: 'Shows' }, - ] as pill (pill.key)} - - {/each} - -
- - - {#if availableGenres.length > 0} -
- - {#if genreOpen} - -
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} - > - - {#each availableGenres as g (g)} - - {/each} -
- {/if} -
- {/if} - - -
- - {#if yearOpen} - -
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} - > - {#each Object.keys(yearPresets) as key (key)} - - {/each} -
- {/if} -
- - -
- - {#if ratingOpen} - -
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} - > - {#each [ - { value: 0, label: 'Any Rating' }, - { value: 7, label: '★ 7+' }, - { value: 8, label: '★ 8+' }, - { value: 9, label: '★ 9+' }, - ] as opt (opt.value)} - - {/each} -
- {/if} -
- - -
- - {#if sortOpen} - -
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} - > - {#each [ - { key: 'default', label: 'Default' }, - { key: 'rating', label: 'Rating ↓' }, - { key: 'year', label: 'Newest' }, - { key: 'title', label: 'A–Z' }, - ] as opt (opt.key)} - - {/each} -
- {/if} -
- - - {#if activeFilterCount > 0} - - {/if} -
-
- {/if} -
- - - - - {#if activeTab === 'discover'} - -
-

- {#if isTyping} - {searching ? 'Searching…' : `${filteredGridItems.length} result${filteredGridItems.length !== 1 ? 's' : ''}`} - {:else if activeFilterCount > 0} - {filteredGridItems.length} match{filteredGridItems.length !== 1 ? 'es' : ''} - {:else} - Trending to Request - {/if} -

-
- - -
- {#if discoverLoading && !isTyping} - -
- {#each Array(12) as _, i (i)} -
- {/each} -
- {:else if filteredGridItems.length === 0 && !searching && !loadingMore} -
-

{isTyping ? 'No results found.' : activeFilterCount > 0 ? 'No items match your filters.' : 'Nothing available.'}

- {#if activeFilterCount > 0} - - {/if} -
- {:else} -
- {#each filteredGridItems as item (item.id)} - {@const alreadyReq = requestedIds.has(item.id) || myTmdbIds.has(item.sourceId)} - {@const isLoading = requestingIds.has(item.id)} - {@const isAvailable = item.status === 'available'} - {@const isPending = item.status === 'requested' || item.status === 'downloading'} - - -
openMedia(item)} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') openMedia(item); }} role="button" tabindex="0"> -
- {#if item.poster} - {item.title} - {:else} -
- - {item.title} -
- {/if} - - - {#if isAvailable} -
In Library
- {:else if isPending || alreadyReq} -
Requested
- {/if} - - -
-
-

{item.title}

- {#if item.year} -

{item.year}{item.rating ? ` · ★ ${item.rating.toFixed(1)}` : ''}

- {/if} - {#if isAvailable} - {#if item.actionUrl} - e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} - class="flex w-full items-center justify-center gap-1 rounded-lg bg-cream py-1.5 text-[11px] font-bold text-black hover:bg-cream/90"> - - Watch - - {/if} - {:else if alreadyReq || isPending} -
- - Requested -
- {:else} - - {/if} -
-
-
- - - -
- {/each} - - - {#if loadingMore || searching} - {#each Array(6) as _, i (i)} -
- {/each} - {/if} -
- - - {#if !isTyping} -
- {#if !discoverHasMore && discoverItems.length > 0} -

You've seen it all.

- {/if} - {/if} - {/if} -
- {/if} - - - {#if activeTab === 'mine' && !data.isAdmin} -
- - {#if !data.hasLinkedOverseerr} -
-
- -
-

Link your account to track requests

-

Connect your Overseerr account in Settings → My Accounts to see your request history here.

- Go to Settings -
- {:else if data.myRequests.length === 0} -
-
- -
-

No requests yet

-

Head to Discover and request something.

- -
- {:else} - -
- {#each [ - { key: 'all', label: 'All', count: myCounts.all }, - { key: 'active', label: 'In Progress', count: myCounts.active }, - { key: 'available', label: 'In Library', count: myCounts.available }, - { key: 'declined', label: 'Declined', count: myCounts.declined }, - ] as tab (tab.key)} - - {/each} -
- - {#if filteredMyRequests.length === 0} -

Nothing in this category.

- {:else} -
- {#each filteredMyRequests as req (req.id)} - -
openReq(req)} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') openReq(req); }} role="button" tabindex="0" - > - - {#if req.backdrop} -
- -
-
- {/if} - -
- -
- {#if req.poster} - {req.title} - {:else} -
- -
- {/if} -
- - -
-
-
-

{req.title}

-

- {#if req.year}{req.year} · {/if}{typeLabel(req.type)}{#if req.rating} · ★ {req.rating.toFixed(1)}{/if} -

-
- {#if req.status === 'available' && req.mediaUrl} - e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} - class="flex-shrink-0 rounded-lg bg-cream px-2.5 py-1.5 text-[11px] font-bold text-black transition hover:bg-cream/90 active:scale-95" - >Watch - {/if} -
- - {#if req.description} -

{req.description}

- {/if} - - -
- {#if req.status === 'declined'} -
-
- -
- Not Approved - · {relativeTime(req.requestedAt)} -
- {:else} - {@const step = statusStep(req.status)} -
- {#each [ - { label: 'Requested', step: 0 }, - { label: 'Approved', step: 1 }, - { label: 'Ready', step: 2 }, - ] as s, i (s.step)} - -
- {#if i > 0} - -
- {/if} -
-
- {#if step >= s.step} - - {/if} -
- {s.label} -
-
- {/each} -
-

- {statusLabel(req.status)} · {relativeTime(req.requestedAt)} -

- {/if} -
-
-
-
- {/each} -
- {/if} - {/if} -
- {/if} - - - {#if activeTab === 'requests' && data.isAdmin} -
- - -
- {#each [ - { key: 'all', label: 'All', count: adminCounts.all }, - { key: 'pending', label: 'Pending', count: adminCounts.pending }, - { key: 'processing', label: 'Processing', count: adminCounts.processing }, - { key: 'available', label: 'In Library', count: adminCounts.available }, - { key: 'declined', label: 'Declined', count: adminCounts.declined }, - ] as tab (tab.key)} - - {/each} -
- - {#if filteredAllRequests.length === 0} -

- {adminFilter === 'all' ? 'No requests yet.' : 'Nothing in this category.'} -

- {:else} -
- {#each filteredAllRequests as req (req.id)} - {@const isOwn = mySourceIds.has(req.sourceId)} - -
openReq(req)} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') openReq(req); }} role="button" tabindex="0" - > - -
- {#if req.poster} - {req.title} - {:else} -
- -
- {/if} -
- - -
-
-

{req.title}

- {#if isOwn} - YOU - {/if} -
-

- {#if req.year}{req.year} · {/if}{typeLabel(req.type)}{#if req.rating} · ★ {req.rating.toFixed(1)}{/if} -

-
- - {req.requestedByName.slice(0, 1).toUpperCase()} - - {req.requestedByName} - · {relativeTime(req.requestedAt)} - - {#if req.status === 'pending'} - Pending - {:else if req.status === 'approved'} - Processing - {:else if req.status === 'available' || req.status === 'partial'} - {req.status === 'partial' ? 'Partial' : 'In Library'} - {:else if req.status === 'declined'} - Declined - {/if} -
-
- - - -
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}> - {#if req.status === 'pending'} - - - {:else if (req.status === 'available' || req.status === 'partial') && req.mediaUrl} - Watch - {/if} -
-
- {/each} -
- {/if} - - {#if actionResult} -
- {actionResult.succeeded} succeeded{actionResult.failed > 0 ? `, ${actionResult.failed} failed` : ''} -
- {/if} -
- {/if} - -{/if} - - -{#if seasonPickerItem} - -
{ if (e.target === e.currentTarget) closeSeasonPicker(); }} - onkeydown={(e) => { if (e.key === 'Escape') closeSeasonPicker(); }} - > - -
-{/if} diff --git a/src/routes/reset-password/+page.server.ts b/src/routes/reset-password/+page.server.ts deleted file mode 100644 index 97bdd284..00000000 --- a/src/routes/reset-password/+page.server.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { fail, redirect } from '@sveltejs/kit'; -import { changePassword, validateSession, COOKIE_NAME } from '$lib/server/auth'; -import type { Actions, PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ cookies }) => { - const token = cookies.get(COOKIE_NAME); - const user = validateSession(token); - if (!user) throw redirect(303, '/login'); - if (!user.forcePasswordReset) throw redirect(303, '/'); - return {}; -}; - -export const actions: Actions = { - default: async ({ request, cookies }) => { - const token = cookies.get(COOKIE_NAME); - const user = validateSession(token); - if (!user) throw redirect(303, '/login'); - - const data = await request.formData(); - const password = data.get('password') as string; - const confirm = data.get('confirm') as string; - - if (!password || password.length < 6) { - return fail(400, { error: 'Password must be at least 6 characters' }); - } - if (password !== confirm) { - return fail(400, { error: 'Passwords do not match' }); - } - - changePassword(user.id, password); - throw redirect(303, '/'); - } -}; diff --git a/src/routes/reset-password/+page.svelte b/src/routes/reset-password/+page.svelte deleted file mode 100644 index ea71771f..00000000 --- a/src/routes/reset-password/+page.svelte +++ /dev/null @@ -1,52 +0,0 @@ - - - - Reset Password — Nexus - - -
-
-
-
- - - -
-
-

Reset Password

-

Your password needs to be changed before continuing.

-
-
- -
(loading = true)}> - {#if form?.error} -
- {form.error} -
- {/if} - -
- - -
-
- - -
- - -
- -
- -
-
-
diff --git a/src/routes/search/+page.server.ts b/src/routes/search/+page.server.ts deleted file mode 100644 index ed04eac6..00000000 --- a/src/routes/search/+page.server.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { unifiedSearch } from '$lib/server/search'; -import { getEnabledConfigs } from '$lib/server/services'; -import { registry } from '$lib/adapters/registry'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ url, locals }) => { - const query = url.searchParams.get('q')?.trim() ?? ''; - const typeFilter = url.searchParams.get('type')?.trim() || undefined; - - if (query.length < 2) { - return { query, typeFilter, items: [], total: 0 }; - } - - const items = await unifiedSearch({ - query, - userId: locals.user?.id, - scope: 'all', - type: typeFilter - }); - - // Mark items produced by a request-provider adapter so the UI can split - // library vs requestable without hardcoding `serviceType === 'overseerr'`. - const requestProviderTypes = new Set( - getEnabledConfigs() - .filter((c) => !!registry.get(c.type)?.getRequests) - .map((c) => c.type) - ); - const tagged = items.map((i) => ({ - ...i, - _requestable: requestProviderTypes.has(i.serviceType) && i.status !== 'available' - })); - - return { query, typeFilter, items: tagged, total: tagged.length }; -}; diff --git a/src/routes/search/+page.svelte b/src/routes/search/+page.svelte deleted file mode 100644 index 75477484..00000000 --- a/src/routes/search/+page.svelte +++ /dev/null @@ -1,192 +0,0 @@ - - - - {data.query ? `"${data.query}" — Nexus` : 'Search — Nexus'} - - -
- {#if !data.query} -
-
- - - - -
-

Search everything

-

Movies, shows, books, games, music — all in one place.

-
- {:else if data.items.length === 0} -
-
- - - - -
-

No results for "{data.query}"

-

Try a different search term or check your connected services.

-
- {:else} -
- - {data.total} result{data.total !== 1 ? 's' : ''} for - - "{data.query}" -
- -
- - {#if libraryItems.length > 0} - {#each Object.entries(typeGroups()) as [type, items]} -
-

{typeLabels[type] ?? type}

-
- {#each items as item, i (`${item.id}-${i}`)} -
- - -
- {/each} -
-
- {/each} - {/if} - - - {#if requestableItems.length > 0} -
-
-

Not in your library?

-

Found via Overseerr — request any of these to add them.

-
-
- {#each requestableItems as item, i (`${item.id}-req-${i}`)} - {@const reqState = requesting[item.id] ?? 'idle'} -
- -
- {#if item.poster} - {item.title} - {:else} -
- -
- {/if} -
- - -
-
- {item.title} - {#if item.year}{item.year}{/if} -
-
- {item.type === 'show' ? 'TV Show' : item.type} - {#if item.rating} - - - {item.rating.toFixed(1)} - - {/if} - {#if item.description} - - {/if} -
-
- - -
- {#if reqState === 'done'} - - - Requested - - {:else if reqState === 'error'} - Failed - {:else} - - {/if} -
-
- {/each} -
-
- {:else if libraryItems.length === 0} - -
-

Nothing found in your library or connected services.

-
- {/if} -
- {/if} -
diff --git a/src/routes/settings/+layout.server.ts b/src/routes/settings/+layout.server.ts deleted file mode 100644 index acecd809..00000000 --- a/src/routes/settings/+layout.server.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { registry } from '$lib/adapters/registry'; -import { getUserCredentials, getUserCredentialForService } from '$lib/server/auth'; -import { getServiceConfigs, autoLinkJellyfinServices } from '$lib/server/services'; -import { getDb, schema } from '$lib/db'; -import { eq } from 'drizzle-orm'; -import type { LayoutServerLoad } from './$types'; - -export const load: LayoutServerLoad = async ({ locals }) => { - const services = getServiceConfigs(); - - // Silently auto-link Overseerr (and similar) via Jellyfin credentials - if (locals.user) { - await autoLinkJellyfinServices(locals.user.id).catch(() => {}); - } - - // Current user's linked credentials — re-read after auto-link above - const myCredentials = locals.user - ? getUserCredentials(locals.user.id).map((c) => ({ - serviceId: c.serviceId, - externalUserId: c.externalUserId, - externalUsername: c.externalUsername, - linkedAt: c.linkedAt - })) - : []; - - // Which services are user-linkable and configured - const linkableServices = services - .filter((s) => { - const adapter = registry.get(s.type); - return (adapter?.userLinkable || s.type === 'streamystats') && s.enabled; - }) - .map((s) => { - const adapter = registry.get(s.type); - - // StreamyStats: auto-connected via Jellyfin token, no stored cred needed - if (s.type === 'streamystats') { - return { - id: s.id, name: s.name, type: s.type, - authUsernameLabel: 'Username', - authMode: 'auto-jellyfin' as const - }; - } - - // Overseerr in Jellyfin auth mode: auto-connected - if (s.type === 'overseerr' && s.username) { - return { - id: s.id, name: s.name, type: s.type, - authUsernameLabel: 'Jellyfin Username', - authMode: 'auto-jellyfin' as const - }; - } - - // Jellyfin: if the admin provisioned or migrated this user, a cred already exists. - if (s.type === 'jellyfin' && locals.user) { - const existing = getUserCredentialForService(locals.user.id, s.id); - if (existing?.externalUserId) { - if (!existing.accessToken) { - return { - id: s.id, name: s.name, type: s.type, - authUsernameLabel: 'Username', - authMode: 'needs-reauth' as const, - prefillUsername: existing.externalUsername ?? '' - }; - } - return { - id: s.id, name: s.name, type: s.type, - authUsernameLabel: 'Username', - authMode: 'auto-provisioned' as const - }; - } - } - - return { - id: s.id, - name: s.name, - type: s.type, - authUsernameLabel: adapter?.authUsernameLabel ?? 'Username', - authMode: 'local' as const - }; - }); - - const isAdmin = locals.user?.isAdmin ?? false; - - // For each linkable service the user doesn't have a credential for, - // count unclaimed accounts for the account picker - const unclaimedCounts: Record = {}; - for (const svc of linkableServices) { - if (myCredentials.some(c => c.serviceId === svc.id)) continue; - const svcConfig = services.find(s => s.id === svc.id); - if (!svcConfig) continue; - const adapter = registry.get(svcConfig.type); - if (!adapter?.getUsers || !adapter?.resetPassword || !adapter?.authenticateUser) continue; - try { - const db = getDb(); - const allCreds = db.select({ externalUserId: schema.userServiceCredentials.externalUserId }) - .from(schema.userServiceCredentials) - .where(eq(schema.userServiceCredentials.serviceId, svc.id)) - .all(); - const claimedIds = new Set(allCreds.map(c => c.externalUserId).filter(Boolean)); - const allUsers = await adapter.getUsers(svcConfig); - unclaimedCounts[svc.id] = allUsers.filter(u => !claimedIds.has(u.externalId)).length; - } catch { - // Ignore errors — service may be offline - } - } - - // Build adapter metadata map for UI (colors, abbreviations) - const adapterMeta: Record = {}; - for (const a of registry.all()) { - adapterMeta[a.id] = { color: a.color, abbreviation: a.abbreviation }; - } - - return { services, myCredentials, linkableServices, isAdmin, unclaimedCounts, adapterMeta }; -}; diff --git a/src/routes/settings/+layout.svelte b/src/routes/settings/+layout.svelte deleted file mode 100644 index 51e2759d..00000000 --- a/src/routes/settings/+layout.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - - - Settings — Nexus - - -
- - {#snippet icon()} - - - - - {/snippet} - - - {@render children()} -
diff --git a/src/routes/settings/+page.server.ts b/src/routes/settings/+page.server.ts deleted file mode 100644 index 3bf34493..00000000 --- a/src/routes/settings/+page.server.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async () => { - throw redirect(302, '/settings/profile'); -}; diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte deleted file mode 100644 index 1ef0183b..00000000 --- a/src/routes/settings/+page.svelte +++ /dev/null @@ -1 +0,0 @@ -

Redirecting...

diff --git a/src/routes/settings/accounts/+page.server.ts b/src/routes/settings/accounts/+page.server.ts deleted file mode 100644 index ab849d7b..00000000 --- a/src/routes/settings/accounts/+page.server.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { registry } from '$lib/adapters/registry'; -import { getUserCredentials, getUserCredentialForService } from '$lib/server/auth'; -import { getEnabledConfigs } from '$lib/server/services'; -import { buildAllAccountServiceSummaries } from '$lib/server/account-services'; -import type { AccountServiceSummary } from '$lib/components/account-linking/types'; -import type { PageServerLoad } from './$types'; - -export interface AccountService { - id: string; - name: string; - type: string; - userLinkable: boolean; - derivedFrom: string[] | null; - parentRequired: boolean; - canCreateUser: boolean; - canAuthenticate: boolean; - isLinked: boolean; - managed: boolean; - linkedVia: string | null; - externalUsername: string | null; - parentLinked: boolean; - parentServiceName: string | null; - authUsernameLabel: string; - color: string; - abbreviation: string; -} - -export const load: PageServerLoad = async ({ locals }) => { - const user = locals.user; - if (!user) { - return { - accountServices: [] as AccountService[], - accountSummaries: [] as AccountServiceSummary[], - isAdmin: false - }; - } - - const configs = getEnabledConfigs(); - const credentials = getUserCredentials(user.id); - const credMap = new Map(credentials.map((c) => [c.serviceId, c])); - - // Build a map of which parent adapter types the user has linked - const linkedTypes = new Set(); - const linkedTypeNames = new Map(); - for (const config of configs) { - const cred = credMap.get(config.id); - if (cred?.accessToken || cred?.externalUserId) { - linkedTypes.add(config.type); - linkedTypeNames.set(config.type, config.name); - } - } - - const accountServices: AccountService[] = []; - - for (const config of configs) { - const adapter = registry.get(config.type); - if (!adapter) continue; - - const userLinkable = adapter.userLinkable ?? false; - const derivedFrom = adapter.derivedFrom ?? null; - - // Only show services where userLinkable === true OR derivedFrom is set - if (!userLinkable && !derivedFrom) continue; - - // Skip enrichment-only services (Bazarr, Prowlarr) — users don't interact with these - if (adapter.isEnrichmentOnly) continue; - - const cred = credMap.get(config.id); - const isLinked = !!(cred?.accessToken || cred?.externalUserId); - - // Check if any parent service is linked - let parentLinked = false; - let parentServiceName: string | null = null; - if (derivedFrom) { - for (const parentType of derivedFrom) { - if (linkedTypes.has(parentType)) { - parentLinked = true; - parentServiceName = linkedTypeNames.get(parentType) ?? parentType; - break; - } - } - } - - accountServices.push({ - id: config.id, - name: config.name, - type: config.type, - userLinkable, - derivedFrom, - parentRequired: adapter.parentRequired ?? false, - canCreateUser: typeof adapter.createUser === 'function', - canAuthenticate: typeof adapter.authenticateUser === 'function', - isLinked, - managed: (cred as any)?.managed ?? false, - linkedVia: (cred as any)?.linkedVia ?? null, - externalUsername: cred?.externalUsername ?? null, - parentLinked, - parentServiceName, - authUsernameLabel: adapter.authUsernameLabel ?? 'Username', - color: adapter.color ?? 'var(--color-accent)', - abbreviation: adapter.abbreviation ?? config.type.slice(0, 2).toUpperCase() - }); - } - - // New normalized summary shape for the shared AccountLinkModal component. - // Runs in parallel with the legacy accountServices shape the existing - // page rendering still depends on — both are kept during the transition. - const accountSummaries = buildAllAccountServiceSummaries(user.id); - - return { - accountServices, - accountSummaries, - isAdmin: user.isAdmin ?? false - }; -}; diff --git a/src/routes/settings/accounts/+page.svelte b/src/routes/settings/accounts/+page.svelte deleted file mode 100644 index 71df841f..00000000 --- a/src/routes/settings/accounts/+page.svelte +++ /dev/null @@ -1,460 +0,0 @@ - - -
-

Linked Accounts

-

- Connect your accounts to enable personalized content, watch history, and recommendations. -

- - - {#if staleSummaries.length > 0} -
- {#each staleSummaries as summary (summary.id)} - invalidateAll()} - /> - {/each} -
- {/if} - - {#if services.length === 0} -
-

- {#if (data as any).isAdmin} - No services configured yet. Add services to get started. - {:else} - No services available yet. - {/if} -

-
- {:else} - - {#if linkedServices.length > 0} -
-

- Connected -

-
- {#each linkedServices as svc (svc.id)} - {@const cascade = getCascadeServices(svc.id)} -
-
-
- {svc.abbreviation} -
-
-
- {svc.name} - - - Connected - -
-

- {#if svc.managed} - Managed by Nexus - {:else if svc.linkedVia} - Linked via {svc.linkedVia} - {:else if svc.externalUsername} - Signed in as @{svc.externalUsername} - {:else} - Linked - {/if} -

-
-
- -
-
-
- {/each} -
-
- {/if} - - - {#if unlinkedServices.length > 0} -
-

- Available Services -

-
- {#each unlinkedServices as svc (svc.id)} -
-
-
- {svc.abbreviation} -
-
- {svc.name} - - - {#if svc.derivedFrom && svc.parentRequired && !svc.parentLinked} -

- Requires {svc.derivedFrom.join(' or ')}. Set it up first. -

- - - {:else if svc.derivedFrom && svc.parentRequired && svc.parentLinked} -

- Auto-link didn't find your account in {svc.name}. Ask your admin to check the setup. -

- - - {:else if svc.derivedFrom && !svc.parentRequired && svc.parentLinked} -

- Auto-link didn't find your account. You can retry or sign in manually. -

- - - {:else if svc.derivedFrom && !svc.parentRequired && !svc.parentLinked} -

- {svc.derivedFrom.join(' or ')} link enables auto-connect, or sign in manually. -

- - - {:else} -

- Not connected -

- {/if} -
-
- - {#if svc.derivedFrom && svc.parentRequired && !svc.parentLinked} - - - - {:else if svc.derivedFrom && svc.parentRequired && svc.parentLinked} - - - - {:else if svc.derivedFrom && svc.parentLinked} - - {#if svc.canAuthenticate} - - {/if} - - - {:else} - {#if svc.canAuthenticate} - - {/if} - {#if svc.canCreateUser} - - {/if} - {/if} -
-
- - - {#if svc.canCreateUser && !svc.derivedFrom} -
-

- "Create Managed" lets Nexus create and manage an account on {svc.name} for you. You won't need to log into {svc.name} directly. -

-
- {/if} -
- {/each} -
-
- {/if} - {/if} -
- - -{#if linkModalSummary} - { - closeLinkModal(); - toast.success(`Connected as ${result.externalUsername}`); - await invalidateAll(); - }} - onCancel={closeLinkModal} - /> -{/if} - - -{#if confirmUnlinkId} - {@const unlinkSvc = services.find((s: any) => s.id === confirmUnlinkId)} - {@const cascade = getCascadeServices(confirmUnlinkId)} - -
e.key === 'Escape' && (confirmUnlinkId = null)} - onclick={(e) => { if (e.target === e.currentTarget) confirmUnlinkId = null; }} - role="dialog" - aria-modal="true" - aria-labelledby="unlink-modal-title" - > -
- - - {#if unlinkSvc?.managed} -
- This is a managed account. Disconnecting will delete the managed account on {unlinkSvc.name}. -
- {/if} - - {#if cascade.length > 0} -

- The following services are linked through {unlinkSvc?.name} and will also be disconnected: -

-
    - {#each cascade as dep} -
  • {dep.name}
  • - {/each} -
- {/if} - - {#if !unlinkSvc?.managed && cascade.length === 0} -

- You can reconnect at any time. -

- {/if} - -
- - -
-
-
-{/if} diff --git a/src/routes/settings/notifications/+page.svelte b/src/routes/settings/notifications/+page.svelte deleted file mode 100644 index 277b7608..00000000 --- a/src/routes/settings/notifications/+page.svelte +++ /dev/null @@ -1,83 +0,0 @@ - - -
-

Notification Preferences

-

Choose which notifications you want to receive.

- - {#if notifPrefsLoading} -
- {#each { length: 6 } as _, i (i)} -
-
-
-
-
-
-
- {/each} -
- {:else} -
- {#each Object.entries(notifTypes) as [type, meta] (type)} -
-
-

{meta.label}

-

{meta.description}

-
- -
- {/each} -
- {/if} -
diff --git a/src/routes/settings/playback/+page.svelte b/src/routes/settings/playback/+page.svelte deleted file mode 100644 index ef4b9e2c..00000000 --- a/src/routes/settings/playback/+page.svelte +++ /dev/null @@ -1,392 +0,0 @@ - - - -
-

Playback Behavior

- -
-
-
-

Autoplay Trailers

-

Automatically play trailers in the hero section on movie and show pages. Defaults to off on mobile devices.

-
- -
- -
-
-

Autoplay Next Episode

-

When an episode ends, start the next one after a 10 second countdown. You can cancel from the up-next card.

-
- -
-
-
- - -
-

Playback Speed Rules

-

Set default playback speeds. More specific rules (video > channel > type) take priority.

- - {#if speedLoading} -
- {#each { length: 3 } as _, i (i)} -
-
-
-
- {/each} -
- {:else} - - {#if speedRules.length > 0} -
- {#each speedRules as rule (rule.id)} -
-
-

- {#if rule.scope === 'default'}Global Default - {:else if rule.scope === 'type'}Type: {rule.scopeName || rule.scopeValue} - {:else if rule.scope === 'channel'}Channel: {rule.scopeName || rule.scopeValue} - {:else}Video: {rule.scopeName || rule.scopeValue}{/if} -

-

{rule.speed}x

-
- -
- {/each} -
- {:else} -

No speed rules yet. Add one below.

- {/if} - - -
-

Add Speed Rule

-
-
- - -
- - {#if newRuleScope !== 'default'} -
- - -
-
- - -
- {/if} - -
- - -
- - -
-
- {/if} -
- - -
-
-

SponsorBlock

- -
-

Skip sponsor segments and other categories in YouTube videos using SponsorBlock.

- - {#if sbLoading} -
- {#each { length: 5 } as _, i (i)} -
-
-
-
- {/each} -
- {:else if sbEnabled} - -
- {#each SB_CATEGORIES as cat (cat.key)} -
-
- -
-

{cat.label}

-

{cat.desc}

-
-
- -
- {/each} -
- - -
-

Display Options

- -
-
-

Show skip notifications

-

Toast when a segment is skipped

-
- -
- - {#if sbShowSkipNotice} -
-
-

Skip notice duration

-

How long the notice shows (ms, 0 = until dismissed)

-
- { sbSkipNoticeDuration = parseInt(e.currentTarget.value) || 0; saveSBPrefs(); }} - /> -
- {/if} -
- {/if} -
diff --git a/src/routes/settings/profile/+page.server.ts b/src/routes/settings/profile/+page.server.ts deleted file mode 100644 index f9e161e3..00000000 --- a/src/routes/settings/profile/+page.server.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { getUserById } from '$lib/server/auth'; - -export const load: PageServerLoad = async ({ locals }) => { - const fullUser = locals.user ? getUserById(locals.user.id) : null; - return { - user: fullUser ? { - id: fullUser.id, - username: fullUser.username, - displayName: fullUser.displayName, - avatar: fullUser.avatar ?? null, - isAdmin: fullUser.isAdmin, - createdAt: fullUser.createdAt - } : null - }; -}; diff --git a/src/routes/settings/profile/+page.svelte b/src/routes/settings/profile/+page.svelte deleted file mode 100644 index fceab350..00000000 --- a/src/routes/settings/profile/+page.svelte +++ /dev/null @@ -1,283 +0,0 @@ - - -{#if data.user} - -
-
- -
- {#if data.user.avatar} - {data.user.displayName} - {:else} -
- {initials} -
- {/if} - -
- -
- {#if editingName} -
- { if (e.key === 'Enter') saveDisplayName(); if (e.key === 'Escape') { editingName = false; displayNameInput = data.user?.displayName ?? ''; } }} - /> - - -
- {:else} -
-

{data.user.displayName}

- -
-

@{data.user.username}

- {/if} -
-
- - {#if nameError} -
-

{nameError}

-
- {/if} - {#if nameSuccess} -
-

Display name updated

-
- {/if} -
- - -
-
-

Account Info

-
-
- Username - @{data.user.username} -
-
- Role - - {data.user.isAdmin ? 'Admin' : 'User'} - -
- {#if data.user.createdAt} -
- Joined - {new Date(data.user.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })} -
- {/if} -
-
-
- - - {#if data.user.avatar} -
-
-
-

Avatar

-

Remove your profile picture to use initials

-
- -
-
- {/if} - - -
-
-
-

Password

- {#if !showPasswordForm} - - {/if} -
-

Change your login password

- - {#if passwordSuccess} -
- Password changed successfully -
- {/if} - - {#if showPasswordForm} -
-
- - -
-
- - -
-
- - - {#if confirmPassword && !passwordsMatch} -

Passwords do not match

- {/if} -
- - {#if passwordError} -
{passwordError}
- {/if} - -
- - -
-
- {/if} -
-
-{:else} -
-

Not logged in.

-
-{/if} diff --git a/src/routes/shows/+page.server.ts b/src/routes/shows/+page.server.ts deleted file mode 100644 index 0d6cdbc2..00000000 --- a/src/routes/shows/+page.server.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { browseLibrary, getConfigsForMediaType, getEnabledConfigs } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { registry } from '$lib/adapters/registry'; -import { withCache } from '$lib/server/cache'; -import type { UnifiedMedia } from '$lib/adapters/types'; -import type { PageServerLoad } from './$types'; - -const PAGE_SIZE = 48; - -export const load: PageServerLoad = async ({ url, locals }) => { - const sortBy = url.searchParams.get('sort') || 'title'; - const q = url.searchParams.get('q')?.trim() ?? ''; - const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10) || 1); - const userId = locals.user?.id; - const hasLibraryService = getConfigsForMediaType('show').length > 0; - - const { - items: libraryItems, - total, - pageSize - } = await browseLibrary({ - type: 'show', - page, - pageSize: PAGE_SIZE, - sortBy, - q, - userId - }); - - // Any adapter that implements getRequests is treated as a request provider - // (Overseerr, Jellyseerr, …) — no hardcoded `registry.get('overseerr')`. - const requestConfigs = getEnabledConfigs().filter( - (c) => !!registry.get(c.type)?.getRequests - ); - const hasOverseerr = requestConfigs.length > 0; - - function dedup(items: UnifiedMedia[]): UnifiedMedia[] { - const seen = new Set(); - return items.filter((i) => { - if (seen.has(i.sourceId)) return false; - seen.add(i.sourceId); - return true; - }); - } - - async function fetchPopularTV(): Promise { - if (requestConfigs.length === 0) return []; - return withCache('shows:popular', 120_000, async () => { - const items: UnifiedMedia[] = []; - await Promise.allSettled( - requestConfigs.map(async (c) => { - const adapter = registry.get(c.type); - if (!adapter?.discover) return; - const cred = userId ? getUserCredentialForService(userId, c.id) ?? undefined : undefined; - const result = await adapter.discover(c, { page: 1, category: 'tv' }, cred); - items.push(...result.items); - }) - ); - return dedup(items).slice(0, 20); - }); - } - - async function fetchTrendingTV(): Promise { - if (requestConfigs.length === 0) return []; - return withCache('shows:trending', 120_000, async () => { - const items: UnifiedMedia[] = []; - await Promise.allSettled( - requestConfigs.map(async (c) => { - const adapter = registry.get(c.type); - if (!adapter?.discover) return; - const cred = userId ? getUserCredentialForService(userId, c.id) ?? undefined : undefined; - const result = await adapter.discover(c, { page: 1, category: 'trending' }, cred); - items.push(...result.items); - }) - ); - return dedup(items).filter((i) => i.type === 'show').slice(0, 20); - }); - } - - return { - libraryItems, - total, - page, - pageSize, - q, - sortBy, - hasLibraryService, - hasOverseerr, - popularTV: fetchPopularTV(), - trendingTV: fetchTrendingTV() - }; -}; diff --git a/src/routes/shows/+page.svelte b/src/routes/shows/+page.svelte deleted file mode 100644 index e41c71d4..00000000 --- a/src/routes/shows/+page.svelte +++ /dev/null @@ -1,256 +0,0 @@ - - - - TV Shows — Nexus - - -
- - {#await data.trendingTV} - {#if data.hasOverseerr} -
- Loading trending shows... -
- {/if} - {:then trendingTV} - {@const hero = pickHero(trendingTV)} - {#if hero} - -
-
-
- - {#if hero.year}{hero.year}{/if} - {#if hero.duration} - · - {formatDuration(hero.duration)} - {/if} - {#if hero.rating} - · - - - {hero.rating.toFixed(1)} - - {/if} -
-

{hero.title}

- {#if hero.genres?.length} -
- {#each hero.genres.slice(0, 3) as genre (genre)} - {genre} - {/each} -
- {/if} - {#if hero.description} -

{hero.description}

- {/if} -
- More Info -
-
- -
-
- {/if} - {:catch} - {#if data.hasOverseerr} -
- Trending shows are unavailable right now. -
- {/if} - {/await} - - -
-
-
-

In Your Library

-

- {data.total} show{data.total === 1 ? '' : 's'} in your collection -

-
-
- Sort by -
- {#each sortOptions as s (s.id)} - {s.label} - {/each} -
-
- -
- - {#if data.libraryItems.length === 0} -
-
- - - -
-

No shows found

-

- {data.libraryItems.length === 0 - ? data.hasLibraryService - ? 'Your TV library is empty, still syncing, or your media service is unavailable right now.' - : 'Connect a media service to populate your library.' - : 'Try adjusting your filters.'} -

- {#if data.libraryItems.length === 0 && !data.hasLibraryService} - Connect a Service - {/if} -
- {:else} -
- {#each data.libraryItems as item (item.id)} - -
onCardHover(item)} - onmouseleave={onCardHoverEnd} - > - -
- {/each} -
- - - {#if totalPages > 1} -
- - Page {data.page} of {totalPages} - -
- {/if} - {/if} -
- - - {#await data.popularTV} - {#if data.hasOverseerr} -
Loading popular shows...
- {/if} - {:then popularTV} - {#if popularTV.length > 0} - - {/if} - {:catch} - {#if data.hasOverseerr} -
Popular shows are unavailable right now.
- {/if} - {/await} -
diff --git a/src/routes/test-play/[backend]/[id]/+page.svelte b/src/routes/test-play/[backend]/[id]/+page.svelte new file mode 100644 index 00000000..7a58dba9 --- /dev/null +++ b/src/routes/test-play/[backend]/[id]/+page.svelte @@ -0,0 +1,479 @@ + + +

test-play — {data.backend} / {data.id}

+ +{#if loading}

negotiating…

{/if} +{#if error}

ERROR: {error}

{/if} + + + + +{#if session} +
+

+ mode: {session.mode} · engine: {session.engine} · + sourceHeight: {session.sourceHeight ?? '?'} +

+

url: {session.url}

+ +
+ quality: + {#each QUALITIES as q} + + {/each} +
+ +
+ subtitles: + + {#each session.subtitleTracks as t (t.id)} + + {:else} + none + {/each} +
+ + {#if session.burnableSubtitleTracks.length} +

burnable (image) subs: {session.burnableSubtitleTracks.map((t) => t.name).join(', ')}

+ {/if} +
+{/if} diff --git a/src/routes/test-play/[backend]/[id]/+page.ts b/src/routes/test-play/[backend]/[id]/+page.ts new file mode 100644 index 00000000..176c5dfb --- /dev/null +++ b/src/routes/test-play/[backend]/[id]/+page.ts @@ -0,0 +1,6 @@ +import type { PageLoad } from './$types'; + +/** Throwaway test-harness loader — just surfaces the route params. */ +export const load: PageLoad = ({ params }) => { + return { backend: params.backend, id: params.id }; +}; diff --git a/src/routes/videos/+page.server.ts b/src/routes/videos/+page.server.ts deleted file mode 100644 index 76f3a975..00000000 --- a/src/routes/videos/+page.server.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { getConfigsForMediaType } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { registry } from '$lib/adapters/registry'; -import { withCache } from '$lib/server/cache'; -import { buildAccountServiceSummary } from '$lib/server/account-services'; -import { runWithAutoRefresh } from '$lib/adapters/registry-auth'; -import { AdapterAuthError } from '$lib/adapters/errors'; -import type { UnifiedMedia } from '$lib/adapters/types'; -import type { AccountServiceSummary } from '$lib/components/account-linking/types'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ locals, url }) => { - const userId = locals.user?.id; - const configs = getConfigsForMediaType('video'); - const hasVideoProvider = configs.length > 0; - const category = (url.searchParams.get('category') as 'music' | 'gaming' | 'news' | 'movies') || undefined; - - let trending: UnifiedMedia[] = []; - let hasLinkedAccount = false; - let invidiousSummary: AccountServiceSummary | null = null; - - if (hasVideoProvider && configs[0]) { - const config = configs[0]; - const cred = userId ? getUserCredentialForService(userId, config.id) ?? undefined : undefined; - hasLinkedAccount = !!cred?.accessToken; - invidiousSummary = buildAccountServiceSummary(userId ?? null, config.id); - - const adapter = registry.get(config.type); - trending = await withCache(`videos:trending:${category ?? 'all'}`, 120_000, () => - adapter?.getServiceData?.(config, 'trending-by-category', { category }) as Promise - ); - - if (hasLinkedAccount && userId) { - // Stream subscription feed — don't block page load. Wrapped in - // runWithAutoRefresh so a stale SID triggers silent refresh via - // refreshCredential + stored_password. If the refresh fails, the - // credential is marked stale and invidiousSummary on the next load - // will show the StaleCredentialBanner. - const subscriptionFeed = withCache(`videos:subfeed:${userId}`, 60_000, async () => { - try { - return await runWithAutoRefresh(config, userId, cred, async (refreshedCred) => { - const feed = await adapter?.getServiceData?.( - config, - 'subscription-feed', - {}, - refreshedCred! - ) as { notifications: UnifiedMedia[]; videos: UnifiedMedia[] } | null; - if (!feed) return [] as UnifiedMedia[]; - return [...feed.notifications, ...feed.videos]; - }); - } catch (err) { - // AdapterAuthError propagates with stale_since already set by - // registry-auth. Plain errors are logged but don't poison state. - if (!AdapterAuthError.is(err)) { - console.error('[videos] subscription feed error:', err); - } - return [] as UnifiedMedia[]; - } - }); - - return { trending, subscriptionFeed, hasVideoProvider, hasLinkedAccount, category, invidiousSummary }; - } - } - - return { - trending, - subscriptionFeed: Promise.resolve([] as UnifiedMedia[]), - hasVideoProvider, - hasLinkedAccount, - category, - invidiousSummary - }; -}; diff --git a/src/routes/videos/+page.svelte b/src/routes/videos/+page.svelte deleted file mode 100644 index fcc89b6d..00000000 --- a/src/routes/videos/+page.svelte +++ /dev/null @@ -1,568 +0,0 @@ - - - - Videos — Nexus - - -
- -

Videos

- - - {#if data.invidiousSummary?.staleSince} - invalidateAll()} - /> - {:else if data.invidiousSummary && data.hasVideoProvider && !data.hasLinkedAccount} - - invalidateAll()} - /> - {/if} - - - {#if data.hasLinkedAccount && data.hasVideoProvider} - - {/if} - - - {#if data.hasVideoProvider} -
-
- - { if (suggestions.length > 0 && searchQuery.trim()) suggestionsOpen = true; }} - placeholder="Search videos & channels..." - class="w-full rounded-xl border border-cream/[0.06] bg-surface py-2.5 pl-9 pr-9 text-sm text-cream placeholder:text-faint outline-none transition-colors focus:border-accent/40 focus:ring-1 focus:ring-accent/20" - /> - {#if searchQuery || isSearchMode} - - {/if} -
- - {#if suggestionsOpen && suggestions.length > 0} - -
e.preventDefault()} - > - {#each suggestions as suggestion, i (suggestion)} - - {/each} -
- {/if} -
- {/if} - - {#if !data.hasVideoProvider} -
-
- -
-

Connect a video service

-

Add a video provider in settings to browse videos.

- - Connect a Service - -
- {:else if isSearchMode} - -
-
- -

- {isSearching ? 'Searching...' : `Results for "${committedQuery}"`} -

- {#if !isSearching && searchResultSummary} - {searchResultSummary} - {/if} -
- -
- {#each [ - { label: 'Relevance', value: 'relevance' }, - { label: 'Date', value: 'date' }, - { label: 'Views', value: 'views' }, - { label: 'Rating', value: 'rating' } - ] as opt (opt.value)} - - {/each} - - | - - {#each [ - { label: 'Any length', value: '' }, - { label: 'Short (<4m)', value: 'short' }, - { label: 'Medium (4-20m)', value: 'medium' }, - { label: 'Long (>20m)', value: 'long' } - ] as opt (opt.value)} - - {/each} -
- - {#if isSearching} -
- {#each { length: 8 } as _, i (i)} -
-
-
-
-
-
-
- {/each} -
- {:else} - - {#if channelResults.length > 0} -
-

- - Channels -

-
- {#each channelResults as channel (channel.id)} - - {/each} -
-
- {/if} - - - {#if searchResults.length > 0} - {#if channelResults.length > 0} -

- - Videos -

- {/if} -
- {#each searchResults as item (item.id)} - handleVideoClick(item)} - onchannelclick={() => goto(`/videos/channel/${item.metadata?.authorId}`)} - /> - {/each} -
- {/if} - - {#if searchResults.length === 0 && channelResults.length === 0} -

No results found for "{committedQuery}"

- {/if} - {/if} -
- {:else} - - - - - - - {#if data.hasLinkedAccount} -
-
-
- -

Your Subscriptions

-
- - See all - - -
- - {#await data.subscriptionFeed} -
- {#each { length: 6 } as _, i (i)} -
-
-
-
-
-
-
- {/each} -
- {:then feed} - {#if feed.length > 0} -
- {#each feed.slice(0, 16) as item (item.id)} -
- handleVideoClick(item)} - onchannelclick={() => goto(`/videos/channel/${item.metadata?.authorId}`)} - /> -
- {/each} -
- {:else} -

No subscription videos available.

- {/if} - {:catch} -

Failed to load subscription feed.

- {/await} -
- {/if} - - - {#if data.trending.length > 0} -
-
- -

Trending

-
- - - {#if data.trending.length >= 2} -
- {#each data.trending.slice(0, 2) as item (item.id)} - handleVideoClick(item)} - onchannelclick={() => goto(`/videos/channel/${item.metadata?.authorId}`)} - /> - {/each} -
- {/if} - - - {#if data.trending.length > 2} -
- {#each data.trending.slice(2, trendingLimit) as item (item.id)} - handleVideoClick(item)} - onchannelclick={() => goto(`/videos/channel/${item.metadata?.authorId}`)} - /> - {/each} -
- {/if} - - - {#if data.trending.length > trendingLimit} -
- {/if} -
- {/if} - - {#if data.trending.length === 0 && !data.hasLinkedAccount} -
-
- -
-

No videos available

-

Check that your Invidious instance is reachable.

-
- {/if} - {/if} -
diff --git a/src/routes/videos/channel/[id]/+page.server.ts b/src/routes/videos/channel/[id]/+page.server.ts deleted file mode 100644 index 3da9b628..00000000 --- a/src/routes/videos/channel/[id]/+page.server.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { getConfigsForMediaType } from '$lib/server/services'; -import { normalizeVideo } from '$lib/adapters/invidious'; -import { registry } from '$lib/adapters/registry'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { isChannelNotifyEnabled } from '$lib/server/video-notifications'; -import { withCache } from '$lib/server/cache'; -import { error } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ params, url, locals }) => { - const configs = getConfigsForMediaType('video'); - if (configs.length === 0) throw error(404, 'No Invidious service configured'); - - const config = configs[0]; - const channelId = params.id; - const sort = url.searchParams.get('sort') ?? undefined; - - const adapter = registry.get(config.type); - const channelData = await withCache(`channel:${channelId}:${sort ?? 'default'}`, 120_000, async () => { - const [channel, videosRes] = await Promise.all([ - adapter?.getServiceData?.(config, 'channel', { channelId }), - adapter?.getServiceData?.(config, 'channel-videos', { channelId, sort }) - ]) as [any, any]; - - return { - author: channel.author as string, - authorId: channel.authorId as string, - description: (channel.description ?? '') as string, - subCount: (channel.subCount ?? 0) as number, - totalViews: (channel.totalViews ?? 0) as number, - authorVerified: (channel.authorVerified ?? false) as boolean, - tags: (channel.tags ?? []) as string[], - // eslint-disable-next-line @typescript-eslint/no-explicit-any - banner: (() => { - const url = (channel.authorBanners?.find((b: any) => b.width >= 1024)?.url ?? - channel.authorBanners?.[0]?.url ?? '') as string; - return url.startsWith('//') ? `https:${url}` : url; - })(), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - thumbnail: (() => { - const url = (channel.authorThumbnails?.find((t: any) => t.width >= 100)?.url ?? '') as string; - return url.startsWith('//') ? `https:${url}` : url; - })(), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - videos: (videosRes.videos?.map((v: any) => normalizeVideo(config, v)) ?? []), - serviceId: config.id - }; - }); - - const userId = locals.user?.id; - const cred = userId ? getUserCredentialForService(userId, config.id) ?? undefined : undefined; - const hasLinkedAccount = !!cred?.accessToken; - let isSubscribed = false; - if (hasLinkedAccount && cred) { - try { - const subs = await adapter?.getServiceData?.(config, 'subscriptions', {}, cred) as any[] ?? []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - isSubscribed = subs.some((s: any) => s.authorId === channelId); - } catch { /* silent */ } - } - - const notifyEnabled = userId ? isChannelNotifyEnabled(userId, channelId) : false; - - return { channel: channelData, sort: sort ?? 'newest', isSubscribed, hasLinkedAccount, notifyEnabled }; -}; diff --git a/src/routes/videos/channel/[id]/+page.svelte b/src/routes/videos/channel/[id]/+page.svelte deleted file mode 100644 index cd18e77b..00000000 --- a/src/routes/videos/channel/[id]/+page.svelte +++ /dev/null @@ -1,245 +0,0 @@ - - - - {data.channel.author} — Nexus - - -
- - {#if data.channel.banner} -
- -
-
- {:else} -
- {/if} - - -
-
- {#if data.channel.thumbnail} - {data.channel.author} - {:else} -
- -
- {/if} - -
-
-

{data.channel.author}

- {#if data.channel.authorVerified} - - {/if} -
-
- {#if data.channel.subCount} - - - {formatCount(data.channel.subCount)} subscribers - - {/if} - {#if data.channel.totalViews} - - - {formatCount(data.channel.totalViews)} views - - {/if} - {#if data.channel.videos.length > 0} - {data.channel.videos.length} videos - {/if} -
-
- - {#if data.hasLinkedAccount} -
- {#if subscribed} - - {/if} - -
- {/if} -
- - - - - -
- - -
- - - {#if activeTab === 'videos'} -
-
- {#each sortOptions as opt (opt.value)} - - {opt.label} - - {/each} -
-
- - {#if data.channel.videos.length > 0} -
- {#each data.channel.videos as item (item.id)} - goto(`/media/video/${item.sourceId}?service=${data.channel.serviceId}`)} - /> - {/each} -
- {:else} -

No videos found for this channel.

- {/if} - - - {:else if activeTab === 'about'} -
- {#if data.channel.description} -
-

Description

-

{data.channel.description}

-
- {/if} - -
- {#if data.channel.totalViews} -
- Total views -

{formatCount(data.channel.totalViews)}

-
- {/if} - {#if data.channel.subCount} -
- Subscribers -

{formatCount(data.channel.subCount)}

-
- {/if} - {#if data.channel.videos.length > 0} -
- Videos -

{data.channel.videos.length}

-
- {/if} -
- - {#if data.channel.tags.length > 0} -
-

Tags

-
- {#each data.channel.tags as tag} - {tag} - {/each} -
-
- {/if} -
- {/if} -
-
diff --git a/src/routes/videos/history/+page.server.ts b/src/routes/videos/history/+page.server.ts deleted file mode 100644 index 87bb0f18..00000000 --- a/src/routes/videos/history/+page.server.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { getConfigsForMediaType } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { registry } from '$lib/adapters/registry'; -import { buildAccountServiceSummary } from '$lib/server/account-services'; -import { runWithAutoRefresh } from '$lib/adapters/registry-auth'; -import { AdapterAuthError } from '$lib/adapters/errors'; -import type { UnifiedMedia } from '$lib/adapters/types'; -import type { AccountServiceSummary } from '$lib/components/account-linking/types'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ locals }) => { - const userId = locals.user?.id; - const configs = getConfigsForMediaType('video'); - if (configs.length === 0) { - return { - videos: [], - hasMore: false, - hasLinkedAccount: false, - invidiousSummary: null as AccountServiceSummary | null - }; - } - - const config = configs[0]; - const cred = userId ? getUserCredentialForService(userId, config.id) ?? undefined : undefined; - const hasLinkedAccount = !!cred?.accessToken; - const invidiousSummary = buildAccountServiceSummary(userId ?? null, config.id); - - if (!hasLinkedAccount || !cred || !userId) { - return { videos: [], hasMore: false, hasLinkedAccount, invidiousSummary }; - } - - const adapter = registry.get(config.type); - try { - const result = await runWithAutoRefresh(config, userId, cred, async (refreshedCred) => { - const videoIds = await adapter?.getServiceData?.(config, 'watch-history', { page: 1 }, refreshedCred!) as string[] ?? []; - const first24 = videoIds.slice(0, 24); - - const videos = ( - await Promise.all( - first24.map(async (id) => { - try { - return await adapter?.getItem?.(config, id, refreshedCred!) ?? null; - } catch { - return null; - } - }) - ) - ).filter((v): v is UnifiedMedia => v !== null); - - return { videos, hasMore: videoIds.length > 24 }; - }); - - return { ...result, hasLinkedAccount, invidiousSummary }; - } catch (err) { - if (!AdapterAuthError.is(err)) { - console.error('[videos/history] feed error:', err); - } - const refreshedSummary = buildAccountServiceSummary(userId, config.id); - return { videos: [], hasMore: false, hasLinkedAccount, invidiousSummary: refreshedSummary }; - } -}; diff --git a/src/routes/videos/history/+page.svelte b/src/routes/videos/history/+page.svelte deleted file mode 100644 index 0813cb4e..00000000 --- a/src/routes/videos/history/+page.svelte +++ /dev/null @@ -1,136 +0,0 @@ - - - - Watch History — Nexus - - -
- -
- - - -
- -

Watch History

-
-
- - {#if data.invidiousSummary?.staleSince} - invalidateAll()} - /> - {:else if !data.hasLinkedAccount && data.invidiousSummary} - invalidateAll()} - /> - {:else if videos.length === 0} -
-
- -
-

No watch history

-

Videos you watch will appear here.

-
- {:else} -
- {#each videos as item (item.id)} -
- handleVideoClick(item)} - /> - - -
- {/each} -
- - {#if hasMore} -
- -
- {/if} - {/if} -
diff --git a/src/routes/videos/playlists/+page.server.ts b/src/routes/videos/playlists/+page.server.ts deleted file mode 100644 index 45550ddb..00000000 --- a/src/routes/videos/playlists/+page.server.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { getConfigsForMediaType } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { registry } from '$lib/adapters/registry'; -import { buildAccountServiceSummary } from '$lib/server/account-services'; -import { runWithAutoRefresh } from '$lib/adapters/registry-auth'; -import { AdapterAuthError } from '$lib/adapters/errors'; -import type { AccountServiceSummary } from '$lib/components/account-linking/types'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ locals }) => { - const userId = locals.user?.id; - const configs = getConfigsForMediaType('video'); - if (configs.length === 0) { - return { - playlists: [], - hasLinkedAccount: false, - invidiousSummary: null as AccountServiceSummary | null - }; - } - - const config = configs[0]; - const cred = userId ? getUserCredentialForService(userId, config.id) ?? undefined : undefined; - const hasLinkedAccount = !!cred?.accessToken; - const invidiousSummary = buildAccountServiceSummary(userId ?? null, config.id); - - if (!hasLinkedAccount || !cred || !userId) { - return { playlists: [], hasLinkedAccount, invidiousSummary }; - } - - try { - const adapter = registry.get(config.type); - const playlists = await runWithAutoRefresh(config, userId, cred, async (refreshedCred) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (await adapter?.getServiceData?.(config, 'playlists', {}, refreshedCred!) as any[]) ?? []; - }); - return { playlists, hasLinkedAccount, invidiousSummary }; - } catch (err) { - if (!AdapterAuthError.is(err)) { - console.error('[videos/playlists] feed error:', err); - } - const refreshedSummary = buildAccountServiceSummary(userId, config.id); - return { playlists: [], hasLinkedAccount, invidiousSummary: refreshedSummary }; - } -}; diff --git a/src/routes/videos/playlists/+page.svelte b/src/routes/videos/playlists/+page.svelte deleted file mode 100644 index 85774a20..00000000 --- a/src/routes/videos/playlists/+page.svelte +++ /dev/null @@ -1,208 +0,0 @@ - - - - Playlists — Nexus - - -
- -
- - - -
- -

Playlists

-
-
- - {#if data.invidiousSummary?.staleSince} - invalidateAll()} - /> - {:else if !data.hasLinkedAccount && data.invidiousSummary} - invalidateAll()} - /> - {:else} - -
-
- - -
-
-
- - -
- -
-
- - - {#if playlists.length === 0} -
-
- -
-

No playlists yet

-

Create your first playlist above.

-
- {:else} -
- {#each playlists as playlist (playlist.playlistId ?? playlist.id)} - {@const id = playlist.playlistId ?? playlist.id} - {@const thumb = getThumbnail(playlist)} - {@const count = getVideoCount(playlist)} - - {/each} -
- {/if} - {/if} -
diff --git a/src/routes/videos/playlists/[id]/+page.server.ts b/src/routes/videos/playlists/[id]/+page.server.ts deleted file mode 100644 index 152f514e..00000000 --- a/src/routes/videos/playlists/[id]/+page.server.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { getConfigsForMediaType } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { normalizeVideo } from '$lib/adapters/invidious'; -import { invidiousCookieHeaders } from '$lib/adapters/invidious/client'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ params, locals }) => { - const userId = locals.user?.id; - const configs = getConfigsForMediaType('video'); - if (configs.length === 0) return { playlist: null }; - - const config = configs[0]; - const cred = userId ? getUserCredentialForService(userId, config.id) ?? undefined : undefined; - - try { - const url = `${config.url}/api/v1/playlists/${encodeURIComponent(params.id)}`; - const headers: Record = { ...invidiousCookieHeaders(cred) }; - - const res = await fetch(url, { - headers, - signal: AbortSignal.timeout(8000) - }); - - if (!res.ok) return { playlist: null }; - - const raw = await res.json(); - - const videos = (raw.videos ?? []).map( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (v: any, i: number) => ({ - ...normalizeVideo(config, v), - // Invidious uses the index ID for removal - _indexId: v.indexId ?? String(i) - }) - ); - - return { - playlist: { - id: raw.playlistId ?? params.id, - title: raw.title ?? 'Untitled', - videoCount: raw.videoCount ?? videos.length, - privacy: raw.privacy ?? 'private', - videos - } - }; - } catch { - return { playlist: null }; - } -}; diff --git a/src/routes/videos/playlists/[id]/+page.svelte b/src/routes/videos/playlists/[id]/+page.svelte deleted file mode 100644 index cdcc5741..00000000 --- a/src/routes/videos/playlists/[id]/+page.svelte +++ /dev/null @@ -1,141 +0,0 @@ - - - - {data.playlist?.title ?? 'Playlist'} — Nexus - - -
- {#if !data.playlist} -
-
- -
-

Playlist not found

-

It may have been deleted or is not accessible.

- - Back to Playlists - -
- {:else} - -
-
- - - -
-

{data.playlist.title}

-

- {data.playlist.videoCount} video{data.playlist.videoCount !== 1 ? 's' : ''} -

-
-
- - -
- - - {#if videos.length === 0} -
-

This playlist is empty.

-
- {:else} -
- {#each videos as video, i (video.id)} -
- {i + 1} -
- handleVideoClick(video.sourceId, video.serviceId)} - /> -
- - -
- {/each} -
- {/if} - {/if} -
diff --git a/src/routes/videos/subscriptions/+page.server.ts b/src/routes/videos/subscriptions/+page.server.ts deleted file mode 100644 index eb81eeca..00000000 --- a/src/routes/videos/subscriptions/+page.server.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { getConfigsForMediaType } from '$lib/server/services'; -import { getUserCredentialForService } from '$lib/server/auth'; -import { registry } from '$lib/adapters/registry'; -import { withCache } from '$lib/server/cache'; -import { buildAccountServiceSummary } from '$lib/server/account-services'; -import { runWithAutoRefresh } from '$lib/adapters/registry-auth'; -import { AdapterAuthError } from '$lib/adapters/errors'; -import type { UnifiedMedia } from '$lib/adapters/types'; -import type { AccountServiceSummary } from '$lib/components/account-linking/types'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ locals }) => { - const userId = locals.user?.id; - const configs = getConfigsForMediaType('video'); - if (configs.length === 0) { - return { - today: [], - thisWeek: [], - earlier: [], - hasLinkedAccount: false, - invidiousSummary: null as AccountServiceSummary | null - }; - } - - const config = configs[0]; - const cred = userId ? getUserCredentialForService(userId, config.id) ?? undefined : undefined; - const hasLinkedAccount = !!cred?.accessToken; - const invidiousSummary = buildAccountServiceSummary(userId ?? null, config.id); - - if (!hasLinkedAccount || !cred || !userId) { - return { today: [], thisWeek: [], earlier: [], hasLinkedAccount, invidiousSummary }; - } - - try { - const adapter = registry.get(config.type); - const allVideos = await withCache(`videos:subfeed:full:${userId}`, 60_000, async () => - runWithAutoRefresh(config, userId, cred, async (refreshedCred) => { - const feed = await adapter?.getServiceData?.(config, 'subscription-feed', {}, refreshedCred!) as { notifications: UnifiedMedia[]; videos: UnifiedMedia[] } | null; - if (!feed) return [] as UnifiedMedia[]; - return [...feed.notifications, ...feed.videos] as UnifiedMedia[]; - }) - ); - - const now = new Date(); - const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const startOfWeek = new Date(startOfToday); - startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay()); - - const today: UnifiedMedia[] = []; - const thisWeek: UnifiedMedia[] = []; - const earlier: UnifiedMedia[] = []; - - for (const video of allVideos) { - const pub = video.metadata?.published as number | undefined; - if (!pub) { earlier.push(video); continue; } - const date = new Date(pub * 1000); - if (date >= startOfToday) today.push(video); - else if (date >= startOfWeek) thisWeek.push(video); - else earlier.push(video); - } - - return { today, thisWeek, earlier, hasLinkedAccount, invidiousSummary }; - } catch (err) { - if (!AdapterAuthError.is(err)) { - console.error('[videos/subscriptions] feed error:', err); - } - // Re-read summary — registry-auth may have just marked it stale, so the - // page needs the fresh state for the banner. - const refreshedSummary = buildAccountServiceSummary(userId, config.id); - return { - today: [], - thisWeek: [], - earlier: [], - hasLinkedAccount, - invidiousSummary: refreshedSummary - }; - } -}; diff --git a/src/routes/videos/subscriptions/+page.svelte b/src/routes/videos/subscriptions/+page.svelte deleted file mode 100644 index df01a37b..00000000 --- a/src/routes/videos/subscriptions/+page.svelte +++ /dev/null @@ -1,138 +0,0 @@ - - - - Subscriptions — Nexus - - -
- -
- - - -
- -

Subscriptions

-
-
- - - - - {#if data.invidiousSummary?.staleSince} - invalidateAll()} - /> - {:else if !data.hasLinkedAccount && data.invidiousSummary} - invalidateAll()} - /> - {:else if data.today.length === 0 && data.thisWeek.length === 0 && data.earlier.length === 0} -
-
- -
-

No subscription videos

-

Subscribe to channels to see their videos here.

-
- {:else} - {#if data.today.length > 0} -
-

- Today ({data.today.length}) -

-
- {#each data.today as item (item.id)} - handleVideoClick(item)} - onchannelclick={() => goto(`/videos/channel/${item.metadata?.authorId}`)} - /> - {/each} -
-
- {/if} - - {#if data.thisWeek.length > 0} -
-

- This Week ({data.thisWeek.length}) -

-
- {#each data.thisWeek as item (item.id)} - handleVideoClick(item)} - onchannelclick={() => goto(`/videos/channel/${item.metadata?.authorId}`)} - /> - {/each} -
-
- {/if} - - {#if data.earlier.length > 0} -
-

- Earlier ({data.earlier.length}) -

-
- {#each data.earlier as item (item.id)} - handleVideoClick(item)} - onchannelclick={() => goto(`/videos/channel/${item.metadata?.authorId}`)} - /> - {/each} -
-
- {/if} - {/if} -
diff --git a/src/routes/welcome/+page.server.ts b/src/routes/welcome/+page.server.ts deleted file mode 100644 index 21dc2b70..00000000 --- a/src/routes/welcome/+page.server.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * /welcome — unified first-run + per-user onboarding (#24). - * - * Handles two modes, toggled by global install state: - * - * A. Fresh install (userCount === 0, no session) - * → load returns { needsAdminCreation: true, ... } - * → page renders an admin-create form - * → `createAccount` action creates the first user, opens a session, - * and reloads the page — load then falls through to mode B. - * - * B. Normal post-login flow (locals.user present) - * → load builds linkableSummaries for the wizard - * → page walks welcome → connect → summary phases - * → `complete` action sets users.welcomeCompletedAt and redirects home. - * - * Unification landed in #24: the old `/setup` route has been retired so - * self-hosters see one URL (`/welcome`) from docker-compose-up through - * finished onboarding. Global install state (userCount) and per-user state - * (welcomeCompletedAt) are still orthogonal — admins pass through /welcome - * exactly once and can re-enter via ?force=1 from Settings. - * - * See docs/superpowers/specs/2026-04-17-surface-drift-fix-plan.md §5. - */ - -import { fail, redirect } from '@sveltejs/kit'; -import { eq } from 'drizzle-orm'; -import { getDb, schema } from '$lib/db'; -import { buildAccountServiceSummariesForType } from '$lib/server/account-services'; -import { registry } from '$lib/adapters/registry'; -import { getEnabledConfigs } from '$lib/server/services'; -import { - COOKIE_NAME, - createSession, - createUser, - getUserCount -} from '$lib/server/auth'; -import type { AccountServiceSummary } from '$lib/components/account-linking/types'; -import type { Actions, PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ locals, url }) => { - // Fresh install: no users yet + no session → render the admin-create form. - // resolveRedirect (rule 2) has already allowed /welcome through specifically - // for this case; we skip the logged-in-only gates below. - if (getUserCount() === 0 && !locals.user) { - return { - needsAdminCreation: true as const, - linkableSummaries: [] as AccountServiceSummary[], - displayName: 'there', - isAdmin: false, - hasAnyServices: false - }; - } - - // Past this point /welcome requires a session. resolveRedirect already - // guarantees locals.user is set when userCount > 0 (rule 3e), but narrow - // for TypeScript and belt-and-braces fallback. - if (!locals.user) throw redirect(303, '/login'); - - // Already completed? Allow force re-run via ?force=1 so users can re-enter - // the flow from Settings → Linked accounts → "Run onboarding again". This - // stays in the route because it reads welcome_completed_at fresh from the - // DB — the resolver only has the cached-at-session-load flag. - const db = getDb(); - const force = url.searchParams.get('force') === '1'; - const row = db - .select({ welcomeCompletedAt: schema.users.welcomeCompletedAt }) - .from(schema.users) - .where(eq(schema.users.id, locals.user.id)) - .get(); - - if (!row) throw redirect(303, '/login'); - if (row.welcomeCompletedAt && !force) { - throw redirect(303, '/'); - } - - // Build summaries for every registered user-linkable service the admin - // has set up. These become the cards in the wizard's connection step. - const configs = getEnabledConfigs(); - const linkableSummaries: AccountServiceSummary[] = []; - for (const config of configs) { - const adapter = registry.get(config.type); - if (!adapter?.capabilities?.userAuth?.userLinkable) continue; - const summaries = buildAccountServiceSummariesForType(locals.user.id, config.type); - for (const s of summaries) { - if (s.id === config.id) linkableSummaries.push(s); - } - } - - return { - needsAdminCreation: false as const, - linkableSummaries, - displayName: locals.user.displayName ?? locals.user.username ?? 'there', - // Admin-on-fresh-install needs to configure services before the wizard's - // personal-account linking step can surface anything. #24 moved admin - // creation into /welcome but the service-registration step from the - // retired /setup route was lost — admins landed at "You're all set" - // without configuring any backends. Codex round 3 P1. - isAdmin: !!locals.user.isAdmin, - hasAnyServices: configs.length > 0 - }; -}; - -export const actions: Actions = { - /** - * Fresh-install admin-create action. Mirrors what the retired /setup - * route's createAccount action did: create the first user as admin, - * open a session cookie, return success so the page reloads into the - * normal wizard phases. Guarded to userCount===0 so it can't be used - * to elevate-to-admin once the install has a user. - */ - createAccount: async ({ request, cookies }) => { - if (getUserCount() !== 0) { - return fail(400, { - error: 'Admin account already exists', - step: 'account' as const - }); - } - - const data = await request.formData(); - const username = (data.get('username') as string)?.trim(); - const displayName = (data.get('displayName') as string)?.trim(); - const password = data.get('password') as string; - const confirm = data.get('confirm') as string; - - if (!username || !displayName || !password) { - return fail(400, { error: 'All fields are required', step: 'account' as const }); - } - if (password.length < 6) { - return fail(400, { - error: 'Password must be at least 6 characters', - step: 'account' as const - }); - } - if (password !== confirm) { - return fail(400, { error: 'Passwords do not match', step: 'account' as const }); - } - - let userId: string; - try { - userId = createUser(username, displayName, password, true); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - if (msg.includes('UNIQUE')) { - return fail(400, { - error: 'That username is already taken', - step: 'account' as const - }); - } - return fail(500, { error: 'Failed to create account', step: 'account' as const }); - } - - const token = createSession(userId); - cookies.set(COOKIE_NAME, token, { - path: '/', - httpOnly: true, - sameSite: 'lax', - maxAge: 60 * 60 * 24 * 30 - }); - - // Redirect back to /welcome — the reload picks up the new session and - // the load function falls through to the wizard-phase branch. - throw redirect(303, '/welcome'); - }, - - complete: async ({ locals }) => { - if (!locals.user) throw redirect(303, '/login'); - const db = getDb(); - db.update(schema.users) - .set({ welcomeCompletedAt: new Date().toISOString() }) - .where(eq(schema.users.id, locals.user.id)) - .run(); - throw redirect(303, '/'); - } -}; diff --git a/src/routes/welcome/+page.svelte b/src/routes/welcome/+page.svelte deleted file mode 100644 index d2a39c1c..00000000 --- a/src/routes/welcome/+page.svelte +++ /dev/null @@ -1,363 +0,0 @@ - - - - Welcome to Nexus - - -
- {#if data.needsAdminCreation} - -
-
-
-

Welcome to Nexus

-

- Create your admin account to get started. -

-
- -
{ - adminCreateLoading = true; - return async ({ update }) => { - await update({ reset: false }); - adminCreateLoading = false; - }; - }} - > - {#if form?.error && form.step === 'account'} -
- {form.error} -
- {/if} - - - -
- - -
- - -
- -
- -
-
-
- {:else} - - -
- {#each ['welcome', 'connect', 'summary'] as p, i (p)} -
- {/each} -
- -
- {#if phase === 'welcome'} -
-
-
-
- - - -
-
- -
-

Welcome, {data.displayName}

-

- Nexus gives you one place to browse and play all your self-hosted media. Your admin - already set up a few services — let's connect your personal accounts so you see - your own library, not just the shared admin view. -

-
- -
- Takes about a minute. You can skip anything and come back from Settings → Accounts. -
- - - - -
- {:else if phase === 'connect'} -
-
-

- {#if summaries.length === 0 && data.isAdmin && !data.hasAnyServices} - Add your first service - {:else} - Connect your accounts - {/if} -

-

- {#if summaries.length === 0 && data.isAdmin && !data.hasAnyServices} - Nexus needs at least one backend to show you anything — Jellyfin, - Plex, Calibre, Invidious, RomM, or any supported service. Head to - Services to connect your first one. - {:else if summaries.length === 0} - Your admin hasn't registered any user-linkable services yet. You're all set. - {:else} - You have {summaries.length} service{summaries.length === 1 ? '' : 's'} available. - Link yours to see your own library, subscriptions, and history. - {/if} -

- {#if summaries.length === 0 && data.isAdmin && !data.hasAnyServices} - - Go to Services → - - {/if} -
- - {#if summaries.length > 0} -
- {#each summaries as summary (summary.id)} - {@const isDone = alreadyLinked.some((s) => s.id === summary.id)} -
-
-
- {summary.abbreviation} -
-
-
{summary.name}
-
- {#if isDone} - ✓ Connected{#if summary.externalUsername} - {' '}as {summary.externalUsername}{/if} - {:else} - {summary.url} - {/if} -
-
-
- {#if isDone} - Done - {:else} - - {/if} -
- {/each} -
- {/if} - -
- - -
-
- {:else if phase === 'summary'} -
-
-
-
- - - -
-
- -
-

You're all set

-

- {#if alreadyLinked.length === 0} - You can connect accounts anytime from Settings → Accounts. - {:else} - {alreadyLinked.length} account{alreadyLinked.length === 1 ? '' : 's'} connected and - ready. - {/if} -

-
- - {#if alreadyLinked.length > 0} -
- {#each alreadyLinked as summary (summary.id)} -
-
- {summary.abbreviation} -
- - {summary.name} - -
- {/each} -
- {/if} - -
- -
-
- {/if} -
- {/if} -
- - -{#if !data.needsAdminCreation && modalSummary} - handleSuccess(modalSummary!.id)} - onCancel={closeModal} - /> -{/if} diff --git a/src/routes/wrapped/+page.server.ts b/src/routes/wrapped/+page.server.ts deleted file mode 100644 index 11e077ff..00000000 --- a/src/routes/wrapped/+page.server.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { computeWrapped } from '$lib/server/wrapped'; - -export const load: PageServerLoad = async ({ url, locals }) => { - if (!locals.user) return { wrapped: null, year: new Date().getFullYear() }; - const year = parseInt(url.searchParams.get('year') ?? String(new Date().getFullYear()), 10); - const wrapped = computeWrapped(locals.user.id, year); - return { wrapped, year }; -}; diff --git a/src/routes/wrapped/+page.svelte b/src/routes/wrapped/+page.svelte deleted file mode 100644 index 78ca28e8..00000000 --- a/src/routes/wrapped/+page.svelte +++ /dev/null @@ -1,798 +0,0 @@ - - - - Nexus Wrapped {year} - - -
-
- - -
- - {#if isEmpty} -
-
{'\u{1F4CA}'}
-

No activity recorded for {year}

-

Start watching, listening, or reading to see your annual review here.

-
- {:else if wrapped} - -
-
-
-

Your

-

{year}

-

in Media

-
- {wrapped.totalHours.toLocaleString()} - hours -
-
-
- - -
-

At a Glance

-
- {#each wrapped.milestones as milestone (milestone.label)} -
- {milestoneIcons[milestone.icon] ?? milestone.icon} - {milestone.value} - {milestone.label} -
- {/each} -
-
- - -
-

By Type

-
- {#each Object.entries(wrapped.byType) as [type, info] (type)} -
- {/each} -
-
- {#each Object.entries(wrapped.byType) as [type, info] (type)} -
- {typeIcons[type] ?? '\u{1F4C1}'} - {type} -
-
-
- {info.hours}h - {info.count} titles -
- {/each} -
-
- - - {#if wrapped.topItems.length > 0} -
-

Top 10

-
- {#each wrapped.topItems as item, i (item.title)} -
- #{i + 1} -
- {item.title} -
- {item.type} - {item.hours}h -
-
-
- {/each} -
-
- {/if} - - - {#if wrapped.topGenres.length > 0} -
-

Top Genres

-
- {#each wrapped.topGenres as genre, i (genre.genre)} -
- {i + 1} - {genre.genre} -
-
-
- {genre.hours}h -
- {/each} -
-
- {/if} - - -
-

Monthly Activity

-
- {#each monthlyData as month (month.label)} -
-
-
-
- {month.label} - {#if month.hours > 0} - {month.hours}h - {/if} -
- {/each} -
-
- {/if} -
- - diff --git a/stream-proxy/Cargo.lock b/stream-proxy/Cargo.lock index 5db69663..a383c718 100644 --- a/stream-proxy/Cargo.lock +++ b/stream-proxy/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -32,15 +41,6 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "bumpalo" version = "3.20.2" @@ -104,15 +104,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -120,14 +111,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] -name = "crypto-common" -version = "0.1.7" +name = "ct-codecs" +version = "1.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] +checksum = "49fb0c6640b4507ebd99ff67677009e381ba5eee1d14df78de4a3d16eb123c39" [[package]] name = "dashmap" @@ -144,15 +131,10 @@ dependencies = [ ] [[package]] -name = "digest" -version = "0.10.7" +name = "deranged" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", - "subtle", -] +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] name = "displaydoc" @@ -165,6 +147,15 @@ dependencies = [ "syn", ] +[[package]] +name = "ed25519-compact" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c24599140dc39d7a81e4476e7573d41bbc18e07c803900298e522a5fbcfbfb6" +dependencies = [ + "getrandom 0.4.2", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -196,6 +187,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -298,16 +295,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -326,10 +313,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -384,15 +373,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - [[package]] name = "http" version = "1.4.0" @@ -772,16 +752,14 @@ dependencies = [ "dashmap", "futures-util", "hex", - "hmac", "http-body-util", "hyper", "hyper-util", "m3u8-rs", - "rand", + "pasetors", "reqwest", "serde", "serde_json", - "sha2", "socket2 0.5.10", "tokio", "urlencoding", @@ -797,6 +775,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -856,6 +840,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "orion" +version = "0.17.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6758747fd1ce1efaf2bd43219ac4aa9e28263b236b2b6a1e486bcd06820707" +dependencies = [ + "fiat-crypto", + "subtle", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -879,6 +873,23 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pasetors" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e838401fb2873bad417e6a03179014c748746f67311cb7317ab14fc0881fa9f0" +dependencies = [ + "ct-codecs", + "ed25519-compact", + "getrandom 0.4.2", + "orion", + "regex", + "serde_json", + "subtle", + "time", + "zeroize", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -913,13 +924,10 @@ dependencies = [ ] [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "powerfmt" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "prettyplease" @@ -956,43 +964,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] -name = "rand" -version = "0.8.5" +name = "redox_syscall" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "libc", - "rand_chacha", - "rand_core", + "bitflags", ] [[package]] -name = "rand_chacha" -version = "0.3.1" +name = "regex" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ - "ppv-lite86", - "rand_core", + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", ] [[package]] -name = "rand_core" -version = "0.6.4" +name = "regex-automata" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ - "getrandom 0.2.17", + "aho-corasick", + "memchr", + "regex-syntax", ] [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "regex-syntax" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -1208,17 +1215,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "shlex" version = "1.3.0" @@ -1344,6 +1340,36 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "time" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -1485,12 +1511,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -1539,12 +1559,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "want" version = "0.3.1" @@ -1928,26 +1942,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zerofrom" version = "0.1.6" diff --git a/stream-proxy/Cargo.toml b/stream-proxy/Cargo.toml index 625b0998..9850831e 100644 --- a/stream-proxy/Cargo.toml +++ b/stream-proxy/Cargo.toml @@ -22,12 +22,16 @@ dashmap = "6" socket2 = "0.5" urlencoding = "2.1.3" m3u8-rs = "6" -hmac = "0.12" -sha2 = "0.10" hex = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" -rand = "0.8" +# PASETO v4.local verify + PASERK k4.local key parsing. Chosen over rusty_paseto +# because pasetors' low-level `version4::LocalToken` API takes the footer + +# implicit assertion as raw &[u8] — exactly what byte-identical cross-language +# compatibility with the Node `paseto-ts` mint needs (no opinionated footer / +# assertion struct serialization to reconcile). Default features give std + v4 + +# paserk; serde_json lets us parse claims. +pasetors = { version = "0.7", features = ["serde_json"] } [profile.release] opt-level = 3 diff --git a/stream-proxy/src/handlers/hls.rs b/stream-proxy/src/handlers/hls.rs index c115ebab..af2cea82 100644 --- a/stream-proxy/src/handlers/hls.rs +++ b/stream-proxy/src/handlers/hls.rs @@ -1,27 +1,25 @@ use crate::session::AdapterKind; use m3u8_rs::{parse_playlist_res, Playlist}; -/// Rewrite a Jellyfin HLS playlist for proxy delivery: +/// Rewrite an HLS playlist for proxy delivery: /// -/// 1. Strip `ApiKey` / `api_key` from all URI query strings so the admin token -/// is never handed to the browser. -/// 2. Rewrite absolute and relative segment/variant URIs to go through the -/// proxy at `/stream/{session_id}/` so the -/// browser never talks to Jellyfin directly. -/// 3. Preserve all `EXT-X-STREAM-INF` attributes (bandwidth, resolution, codecs). +/// 1. Strip `ApiKey` / `api_key` / `X-Plex-Token` (all casings) from every URI +/// query so the held service credential is never handed to the browser. +/// 2. Rewrite variant / segment / `#EXT-X-MEDIA` (audio+subs) / `#EXT-X-KEY` +/// URIs to Nexus-origin grant URLs: +/// `{url_prefix}stream?grant=&suffix=`. +/// The browser carries the SAME grant back on every child hop — no per-hop +/// credential, no client-named upstream URL. +/// 3. Preserve all `EXT-X-STREAM-INF` attributes; fix Jellyfin's occasional +/// bogus `BANDWIDTH=` (m3u8-rs re-serialization normalizes it). /// -/// Returns the rewritten manifest bytes. -/// -/// `manifest_url` is the FULL URL the proxy fetched this manifest from. -/// Segment and variant URIs inside the manifest may be relative (e.g. -/// `00000.ts`, `session//base/index.m3u8`); they're resolved against -/// `manifest_url` to absolute URLs before being hex-encoded, so the return -/// trip through the session handler can dispatch to the right upstream -/// even for nested manifests (master → variant → segment). +/// `manifest_url` is the FULL upstream URL the proxy fetched this manifest from; +/// relative child URIs are absolutized against it before hex-encoding so the +/// return trip resolves to the right upstream even for nested manifests +/// (master → variant → segment). pub fn rewrite_manifest( raw: &[u8], - session_id: &str, - sig: &str, + grant: &str, url_prefix: &str, manifest_url: &str, kind: AdapterKind, @@ -30,11 +28,11 @@ pub fn rewrite_manifest( match parsed { Playlist::MasterPlaylist(mut master) => { for variant in &mut master.variants { - variant.uri = rewrite_uri(&variant.uri, session_id, sig, url_prefix, manifest_url); + variant.uri = rewrite_uri(&variant.uri, grant, url_prefix, manifest_url); } for media in &mut master.alternatives { if let Some(uri) = media.uri.take() { - media.uri = Some(rewrite_uri(&uri, session_id, sig, url_prefix, manifest_url)); + media.uri = Some(rewrite_uri(&uri, grant, url_prefix, manifest_url)); } } let mut out = Vec::new(); @@ -45,13 +43,13 @@ pub fn rewrite_manifest( } Playlist::MediaPlaylist(mut media) => { for segment in &mut media.segments { - segment.uri = rewrite_uri(&segment.uri, session_id, sig, url_prefix, manifest_url); + segment.uri = rewrite_uri(&segment.uri, grant, url_prefix, manifest_url); if let Some(map) = &mut segment.map { - map.uri = rewrite_uri(&map.uri, session_id, sig, url_prefix, manifest_url); + map.uri = rewrite_uri(&map.uri, grant, url_prefix, manifest_url); } if let Some(key) = &mut segment.key { if let Some(uri) = key.uri.take() { - key.uri = Some(rewrite_uri(&uri, session_id, sig, url_prefix, manifest_url)); + key.uri = Some(rewrite_uri(&uri, grant, url_prefix, manifest_url)); } } } @@ -59,13 +57,9 @@ pub fn rewrite_manifest( media .write_to(&mut out) .map_err(|e| format!("write media: {e}"))?; - // Plex's transcoder writes "live-style" playlists (no VERSION, - // no PLAYLIST-TYPE, no ENDLIST, implicit MEDIA-SEQUENCE=0) even - // though all segments are declared upfront. HLS.js treats that - // as live-edge and stalls in STOPPED->IDLE. Jellyfin already - // emits proper VOD manifests, so only run the normalization for - // the Plex adapter — keeps the Jellyfin bytes identical to what - // m3u8_rs emits on re-serialize (no extra tags, no confusion). + // Plex writes live-style playlists (no VERSION/PLAYLIST-TYPE/ENDLIST) + // for what's actually VOD — HLS.js stalls at live-edge. Only normalize + // for Plex; Jellyfin already emits proper VOD manifests. if kind == AdapterKind::Plex { out = normalize_media_playlist(out, media.media_sequence); } @@ -74,17 +68,8 @@ pub fn rewrite_manifest( } } -/// Inject the tags HLS.js needs to treat a Plex-style playlist as VOD. -/// Inserts (when missing): #EXT-X-VERSION:3, #EXT-X-MEDIA-SEQUENCE, and -/// #EXT-X-PLAYLIST-TYPE:VOD right after the opening #EXTM3U. Appends -/// #EXT-X-ENDLIST at end-of-file if absent. Idempotent for Jellyfin's -/// already-well-formed manifests since their tags are already present. fn normalize_media_playlist(bytes: Vec, media_sequence: u64) -> Vec { let text = String::from_utf8_lossy(&bytes).into_owned(); - - // Build the header-level tags we want to guarantee are present, in the - // spec-recommended order: VERSION, TARGETDURATION, MEDIA-SEQUENCE, - // PLAYLIST-TYPE, then everything else. let has_version = text.contains("#EXT-X-VERSION"); let has_media_sequence = text.contains("#EXT-X-MEDIA-SEQUENCE"); let has_playlist_type = text.contains("#EXT-X-PLAYLIST-TYPE"); @@ -104,7 +89,6 @@ fn normalize_media_playlist(bytes: Vec, media_sequence: u64) -> Vec { let with_header = if injects.is_empty() { text } else if let Some(idx) = text.find("#EXTM3U") { - // Insert right after the EXTM3U header line. let after = text[idx..].find('\n').map(|n| idx + n + 1).unwrap_or(text.len()); let mut s = String::with_capacity(text.len() + injects.len()); s.push_str(&text[..after]); @@ -112,8 +96,6 @@ fn normalize_media_playlist(bytes: Vec, media_sequence: u64) -> Vec { s.push_str(&text[after..]); s } else { - // Corrupt manifest without #EXTM3U — leave it alone, let the client - // error so we notice. text }; @@ -127,26 +109,22 @@ fn normalize_media_playlist(bytes: Vec, media_sequence: u64) -> Vec { s.push_str("#EXT-X-ENDLIST\n"); s }; - with_endlist.into_bytes() } -/// Strip `ApiKey` / `api_key` and rewrite a single URI to `/stream/{id}/`. -/// -/// The URI is first absolutized against `manifest_url` so nested manifests -/// (master → variant → segment) resolve to the correct upstream path on the -/// return trip. Then the ApiKey stripper runs, then the absolute URL is -/// hex-encoded. -fn rewrite_uri(uri: &str, session_id: &str, sig: &str, url_prefix: &str, manifest_url: &str) -> String { +/// Strip `ApiKey`/`api_key`, absolutize against `manifest_url`, hex-encode, and +/// emit `{prefix}stream?grant=&suffix=`. +fn rewrite_uri(uri: &str, grant: &str, url_prefix: &str, manifest_url: &str) -> String { let absolute = absolutize_uri(uri, manifest_url); let stripped = strip_auth_query(&absolute); let clean_prefix = url_prefix.trim_end_matches('/'); - format!("{clean_prefix}/{session_id}/{}?sig={sig}", hex::encode(stripped.as_bytes())) + format!( + "{clean_prefix}/stream?grant={}&suffix={}", + urlencoding::encode(grant), + hex::encode(stripped.as_bytes()) + ) } -/// Resolve a (possibly relative) URI against a base URL, returning an -/// absolute http(s) URL. Mirrors the logic in `session::resolve_relative`; -/// kept here to avoid a cross-module dependency. fn absolutize_uri(uri: &str, base: &str) -> String { if uri.starts_with("http://") || uri.starts_with("https://") { return uri.to_string(); @@ -157,7 +135,6 @@ fn absolutize_uri(uri: &str, base: &str) -> String { .and_then(|i| base_no_query[i + 3..].find('/').map(|j| i + 3 + j)) .unwrap_or(base_no_query.len()); let origin = &base_no_query[..origin_end]; - if uri.starts_with('/') { return format!("{origin}{uri}"); } @@ -175,7 +152,9 @@ fn strip_auth_query(uri: &str) -> String { .split('&') .filter(|p| { let name = p.split_once('=').map(|(k, _)| k).unwrap_or(p); - !name.eq_ignore_ascii_case("apikey") && !name.eq_ignore_ascii_case("api_key") + !name.eq_ignore_ascii_case("apikey") + && !name.eq_ignore_ascii_case("api_key") + && !name.eq_ignore_ascii_case("x-plex-token") }) .collect(); if kept.is_empty() { @@ -199,37 +178,50 @@ mod tests { strip_auth_query("/segment.ts?foo=1&api_key=abc&bar=2"), "/segment.ts?foo=1&bar=2" ); - assert_eq!( - strip_auth_query("/segment.ts?ApiKey=abc"), - "/segment.ts" - ); + assert_eq!(strip_auth_query("/segment.ts?ApiKey=abc"), "/segment.ts"); assert_eq!(strip_auth_query("/segment.ts"), "/segment.ts"); } #[test] - fn rewrite_uri_produces_proxy_path() { - let out = rewrite_uri("/Videos/abc/hls1/main/0.ts?ApiKey=secret", "sess123", "testsig", "/stream/", "http://jf.local/Videos/abc/master.m3u8"); - assert!(out.starts_with("/stream/sess123/")); - assert!(!out.contains("secret"), "api key must be absent"); - assert!(!out.contains("ApiKey"), "api key param name must be absent"); + fn strip_auth_removes_plex_token_casings() { + // Plex transcode manifests embed X-Plex-Token on segment URLs; it must be + // stripped (case-insensitive) so the held cred never reaches the browser. + assert_eq!( + strip_auth_query("/segment.ts?foo=1&X-Plex-Token=abc&bar=2"), + "/segment.ts?foo=1&bar=2" + ); + assert_eq!( + strip_auth_query("/segment.ts?foo=1&x-plex-token=abc&bar=2"), + "/segment.ts?foo=1&bar=2" + ); + assert_eq!(strip_auth_query("/segment.ts?X-Plex-Token=abc"), "/segment.ts"); } #[test] - fn rewrite_uri_includes_sig_query() { - let out = rewrite_uri("/Videos/abc/main.m3u8?ApiKey=x", "s1", "mysig", "/stream/", "http://jf.local/Videos/abc/master.m3u8"); - assert!(out.starts_with("/stream/s1/")); - assert!(out.contains("?sig=mysig"), "sig must be embedded for router discrimination"); - assert!(!out.contains("ApiKey")); + fn rewrite_uri_produces_grant_path() { + let out = rewrite_uri( + "/Videos/abc/hls1/main/0.ts?ApiKey=secret", + "v4.local.TOKEN", + "/stream/", + "http://jf.local/Videos/abc/master.m3u8", + ); + assert!(out.starts_with("/stream/stream?grant=v4.local.TOKEN&suffix=")); + assert!(!out.contains("secret"), "api key must be absent"); + assert!(!out.contains("ApiKey"), "api key param name must be absent"); } #[test] fn rewrite_uri_honors_url_prefix() { - let out = rewrite_uri("/Videos/abc/main.m3u8?ApiKey=x", "s1", "sig1", "/api/stream-proxy/", "http://jf.local/Videos/abc/master.m3u8"); + let out = rewrite_uri( + "/Videos/abc/main.m3u8?ApiKey=x", + "TOK", + "/api/stream-proxy/", + "http://jf.local/Videos/abc/master.m3u8", + ); assert!( - out.starts_with("/api/stream-proxy/s1/"), + out.starts_with("/api/stream-proxy/stream?grant=TOK&suffix="), "expected url_prefix honored, got: {out}" ); - assert!(out.contains("?sig=sig1")); assert!(!out.contains("ApiKey")); } @@ -240,13 +232,20 @@ mod tests { #EXT-X-STREAM-INF:BANDWIDTH=1280000,RESOLUTION=640x360,CODECS=\"avc1.64001f,mp4a.40.2\" /Videos/abc/main.m3u8?ApiKey=leaky "; - let out = rewrite_manifest(input, "s1", "testsig", "/stream/", "http://jf.local/Videos/abc/master.m3u8", AdapterKind::Jellyfin).expect("parses and rewrites"); - let out_str = std::str::from_utf8(&out).unwrap(); - assert!(out_str.contains("BANDWIDTH=1280000"), "preserves bandwidth"); - assert!(out_str.contains("RESOLUTION=640x360"), "preserves resolution"); - assert!(!out_str.contains("leaky"), "strips api key"); - assert!(out_str.contains("/stream/s1/"), "rewrites URI through proxy"); - assert!(out_str.contains("?sig=testsig"), "embeds sig in rewritten URIs"); + let out = rewrite_manifest( + input, + "TOK", + "/stream/", + "http://jf.local/Videos/abc/master.m3u8", + AdapterKind::Jellyfin, + ) + .expect("parses and rewrites"); + let s = std::str::from_utf8(&out).unwrap(); + assert!(s.contains("BANDWIDTH=1280000"), "preserves bandwidth"); + assert!(s.contains("RESOLUTION=640x360"), "preserves resolution"); + assert!(!s.contains("leaky"), "strips api key"); + assert!(s.contains("grant=TOK"), "carries grant on child hop"); + assert!(s.contains("&suffix="), "hex-encodes upstream as suffix"); } #[test] @@ -260,11 +259,17 @@ mod tests { /Videos/abc/hls1/main/1.ts?ApiKey=leaky #EXT-X-ENDLIST "; - let out = rewrite_manifest(input, "s1", "testsig", "/stream/", "http://jf.local/Videos/abc/master.m3u8", AdapterKind::Jellyfin).expect("parses and rewrites"); - let out_str = std::str::from_utf8(&out).unwrap(); - assert!(!out_str.contains("leaky")); - assert!(out_str.contains("/stream/s1/")); - assert!(out_str.contains("?sig=testsig"), "embeds sig in segment URIs"); - assert!(out_str.contains("#EXTINF:6"), "preserves EXTINF"); + let out = rewrite_manifest( + input, + "TOK", + "/stream/", + "http://jf.local/Videos/abc/master.m3u8", + AdapterKind::Jellyfin, + ) + .expect("parses and rewrites"); + let s = std::str::from_utf8(&out).unwrap(); + assert!(!s.contains("leaky")); + assert!(s.contains("grant=TOK")); + assert!(s.contains("#EXTINF:6"), "preserves EXTINF"); } } diff --git a/stream-proxy/src/handlers/invidious.rs b/stream-proxy/src/handlers/invidious.rs index 226c682b..cbeccf4e 100644 --- a/stream-proxy/src/handlers/invidious.rs +++ b/stream-proxy/src/handlers/invidious.rs @@ -7,7 +7,9 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use crate::proxy::{empty_body, BoxError}; +use crate::proxy::{cached_or_stream, empty_body, full_body, BoxError, HTTP_CLIENT as PROXY_HTTP_CLIENT}; +use crate::session::{self, HeldCred}; +use std::collections::HashMap; // ── CDN cache ────────────────────────────────────────────────────────────── @@ -524,6 +526,284 @@ pub async fn handle( builder.body(tracked_body.boxed()).unwrap() } +/// Invidious-style grant entry: `GET /v/{id}/...?grant=`. +/// +/// Shares the grant-verify + held-cred spine with the Jellyfin `/stream` route. +/// The grant binds `{backend, resource_ref=videoId, user_id}`; the proxy holds +/// the Invidious service cred and fetches everything through the instance origin +/// (`local=true` upstreams), so the injected auth is always the Nexus service +/// token, never the browser's. Range passthrough + segment cache via +/// `cached_or_stream`. +/// +/// Path shapes (the `id` is the videoId the grant authorizes): +/// /v/{id} → progressive/dash entry, resource_ref resolution +/// /v/{id}/seg/{hex} → an instance-anchored segment (hex = upstream path) +/// /v/{id}/captions?label= → caption proxy +pub async fn handle_v( + req: Request, + invidious_url: Arc, +) -> Response> { + let path = req.uri().path().to_string(); + let query = req.uri().query().unwrap_or("").to_string(); + + let grant_token = match query.split('&').find_map(|p| p.strip_prefix("grant=")) { + Some(g) => urlencoding::decode(g).map(|c| c.into_owned()).unwrap_or_else(|_| g.to_string()), + None => return forbidden("missing grant"), + }; + + // Identity binding via the seam-stamped header — FAIL CLOSED if absent + // (adversarial review: dropped the "legacy" default that nulled user binding). + let expected_user = match req + .headers() + .get("x-nexus-user") + .and_then(|v| v.to_str().ok()) + .filter(|s| !s.is_empty()) + { + Some(u) => u.to_string(), + None => return forbidden("missing user identity"), + }; + + let grant = match session::verify_grant(&grant_token, &expected_user, 0) { + Ok(g) => g, + Err(e) => { + eprintln!("[stream-proxy] /v grant rejected: {e:?}"); + return forbidden("invalid grant"); + } + }; + + // Resolve the held Invidious cred fail-closed. The instance base comes from + // the held cred if present, else the env INVIDIOUS_URL (anon/public path — + // Invidious content needs no auth). + let (base, auth_headers): (String, HashMap) = + match session::held_cred(&grant.claims.backend) { + Some(HeldCred { + base_url, + auth_header_name, + auth_header_value, + }) => { + let mut h = HashMap::new(); + if !auth_header_name.is_empty() { + h.insert(auth_header_name, auth_header_value); + } + (base_url, h) + } + // No held cred: Invidious content is public; fall back to the env + // instance origin. This is still grant-gated (verified above). + None => ((*invidious_url).clone(), HashMap::new()), + }; + + // Parse `/v/{id}/...` — the {id} segment must match the grant's resource_ref + // (the videoId the grant authorizes), else reject (resource scoping). + let rest = path.trim_start_matches("/v/"); + let mut parts = rest.splitn(2, '/'); + let id = parts.next().unwrap_or(""); + let tail = parts.next().unwrap_or(""); + if id.is_empty() || id != grant.claims.resource_ref { + return forbidden("grant/resource mismatch"); + } + + // ── DASH manifest entry: GET /v/{id}/dash?grant= ───────────────── + // Fetch the instance DASH manifest (following the 302 to /companion/...), + // rewrite every per-Representation so the byte-range segment + // fetches route back through this proxy carrying the SAME grant, and serve + // the rewritten MPD. The child hops land on the `seg/{hex}` branch below, + // which decodes the instance-relative path and streams it with Range + // passthrough via cached_or_stream. + if tail == "dash" { + let manifest_url = format!( + "{}/api/manifest/dash/id/{}?local=true", + base.trim_end_matches('/'), + urlencoding::encode(id) + ); + // PROXY_HTTP_CLIENT follows redirects (limited(5)), so the 302 to the + // relative /companion/... MPD resolves in a single GET. + let mut req_builder = PROXY_HTTP_CLIENT.get(&manifest_url); + for (k, v) in &auth_headers { + req_builder = req_builder.header(k, v); + } + let upstream = match req_builder.send().await { + Ok(r) => r, + Err(e) => { + eprintln!("[stream-proxy] DASH manifest fetch error: {e}"); + return bad_gateway("dash upstream error"); + } + }; + let status = upstream.status().as_u16(); + let mpd = match upstream.text().await { + Ok(t) => t, + Err(e) => { + eprintln!("[stream-proxy] DASH manifest body read: {e}"); + return bad_gateway("dash upstream body"); + } + }; + let rewritten = rewrite_dash_manifest(&mpd, id, &grant_token); + return Response::builder() + .status(StatusCode::from_u16(status).unwrap_or(StatusCode::OK)) + .header("content-type", "application/dash+xml") + .header("cache-control", "no-store") + .body(full_body(rewritten)) + .unwrap(); + } + + // Resolve the upstream URL against the instance origin. + let upstream_url = if let Some(hex_path) = tail.strip_prefix("seg/") { + // Instance-anchored segment: hex-decode the upstream path. + let bytes = match hex::decode(hex_path.split('?').next().unwrap_or(hex_path)) { + Ok(b) => b, + Err(_) => return bad_request("bad seg hex"), + }; + let sub = match std::str::from_utf8(&bytes) { + Ok(s) => s.to_string(), + Err(_) => return bad_request("seg not utf-8"), + }; + // SECURITY (adversarial review): `sub` is attacker-controlled (hex from the + // URL). join_instance passes absolute URLs through verbatim → SSRF + held- + // cred exfil to any host. Constrain to instance-relative companion/ + // videoplayback paths (the same allowlist the DASH rewriter emits) and + // re-anchor to the configured instance. Anything else is rejected. + let safe = match instance_relative_path(&sub) { + Some(p) => p, + None => return forbidden("segment path not allowed"), + }; + join_instance(&base, &safe) + } else if tail == "captions" { + // Caption proxy: /api/v1/captions/{id}?. + let kept = strip_grant(&query); + if kept.is_empty() { + format!("{}/api/v1/captions/{}", base.trim_end_matches('/'), id) + } else { + format!("{}/api/v1/captions/{}?{}", base.trim_end_matches('/'), id, kept) + } + } else { + // Entry hit: the resource_ref is treated as an instance-relative path the + // adapter baked at mint time (e.g. a DASH manifest or latest_version URL). + // For Phase-0 the resource_ref IS the videoId; resolve to the canonical + // progressive entry. The adapter-build phase refines this mapping. + format!( + "{}/latest_version?id={}&local=true", + base.trim_end_matches('/'), + urlencoding::encode(id) + ) + }; + + cached_or_stream(&upstream_url, &auth_headers, req.headers()).await +} + +fn join_instance(base: &str, sub: &str) -> String { + if sub.starts_with("http://") || sub.starts_with("https://") { + return sub.to_string(); + } + let b = base.trim_end_matches('/'); + if sub.starts_with('/') { + format!("{b}{sub}") + } else { + format!("{b}/{sub}") + } +} + +/// URL-prefix the proxy anchors child hops under. Matches how the HLS rewrite +/// roots its grant URLs (the Nexus stream-proxy mount). +const URL_PREFIX: &str = "/api/stream-proxy/"; + +/// Rewrite every `...` in a DASH MPD whose inner content is +/// an instance-relative path (`/companion/...` or `/videoplayback...`, in either +/// relative or absolute-to-instance form) into a grant-bearing proxy URL: +/// `{URL_PREFIX}v/{id}/seg/{HEX}?grant={grant}` +/// where HEX = lowercase hex of the UTF-8 bytes of the instance path `P` (the +/// `seg/{hex}` branch decodes this back and resolves it against the instance +/// origin via join_instance). The `&` in the emitted query is XML-escaped to +/// `&` since it lives inside the MPD. +fn rewrite_dash_manifest(mpd: &str, id: &str, grant: &str) -> String { + let open = ""; + let close = ""; + let mut out = String::with_capacity(mpd.len()); + let mut rest = mpd; + while let Some(start) = rest.find(open) { + let after_open = start + open.len(); + let inner_end = match rest[after_open..].find(close) { + Some(i) => after_open + i, + None => break, // unbalanced tag — emit the remainder verbatim below + }; + // Emit everything up to and including the open tag. + out.push_str(&rest[..after_open]); + let inner_raw = &rest[after_open..inner_end]; + // MPD content is XML-escaped (`&`); decode to the real path before + // hex-encoding so the round-trip path matches the upstream exactly. + let inner = inner_raw.replace("&", "&"); + if let Some(path) = instance_relative_path(&inner) { + let hex = hex::encode(path.as_bytes()); + let replacement = format!( + "{URL_PREFIX}v/{}/seg/{}?grant={}", + id, + hex, + urlencoding::encode(grant) + ); + // XML-escape the `&` between query params for valid MPD content. + out.push_str(&replacement.replace('&', "&")); + } else { + out.push_str(inner_raw); + } + out.push_str(close); + rest = &rest[inner_end + close.len()..]; + } + out.push_str(rest); + out +} + +/// If `inner` references an instance-relative companion/videoplayback resource, +/// return the path component to anchor against the instance origin (leading +/// slash preserved). Accepts both relative (`/companion/...`, `/videoplayback`) +/// and absolute-to-instance (`http(s)://host/companion/...`) forms; returns the +/// origin-relative path in both cases. Returns None for anything else. +fn instance_relative_path(inner: &str) -> Option { + let trimmed = inner.trim(); + if trimmed.starts_with("/companion/") || trimmed.starts_with("/videoplayback") { + return Some(trimmed.to_string()); + } + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + // Strip scheme://host, keep the origin-relative path+query. + let after_scheme = trimmed.splitn(2, "://").nth(1)?; + let slash = after_scheme.find('/')?; + let path = &after_scheme[slash..]; + if path.starts_with("/companion/") || path.starts_with("/videoplayback") { + return Some(path.to_string()); + } + } + None +} + +fn strip_grant(query: &str) -> String { + query + .split('&') + .filter(|p| !p.starts_with("grant=")) + .collect::>() + .join("&") +} + +fn forbidden(msg: &str) -> Response> { + Response::builder() + .status(403) + .header("content-type", "application/json") + .body(full_body(serde_json::json!({ "error": msg }).to_string())) + .unwrap() +} + +fn bad_request(msg: &str) -> Response> { + Response::builder() + .status(400) + .header("content-type", "application/json") + .body(full_body(serde_json::json!({ "error": msg }).to_string())) + .unwrap() +} + +fn bad_gateway(msg: &str) -> Response> { + Response::builder() + .status(502) + .header("content-type", "application/json") + .body(full_body(serde_json::json!({ "error": msg }).to_string())) + .unwrap() +} + /// Body wrapper that decrements active connections when dropped struct TrackedBody { inner: BoxBody, @@ -554,3 +834,84 @@ impl Drop for TrackedBody { STATS.active_connections.fetch_sub(1, Ordering::Relaxed); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn instance_relative_path_accepts_relative_companion() { + assert_eq!( + instance_relative_path("/companion/videoplayback?expire=1&id=x").as_deref(), + Some("/companion/videoplayback?expire=1&id=x") + ); + assert_eq!( + instance_relative_path("/videoplayback?foo=bar").as_deref(), + Some("/videoplayback?foo=bar") + ); + } + + #[test] + fn instance_relative_path_strips_absolute_origin() { + assert_eq!( + instance_relative_path("https://inv.local/companion/videoplayback?a=1").as_deref(), + Some("/companion/videoplayback?a=1") + ); + } + + #[test] + fn instance_relative_path_rejects_foreign() { + assert_eq!(instance_relative_path("https://googlevideo.com/x"), None); + assert_eq!(instance_relative_path("/api/v1/something"), None); + assert_eq!(instance_relative_path("relative/path"), None); + } + + #[test] + fn rewrite_dash_rewrites_baseurl_with_grant() { + let mpd = r#"/companion/videoplayback?expire=1&id=abc&itag=137"#; + let out = rewrite_dash_manifest(mpd, "VIDID", "v4.local.TOK"); + // Path P = /companion/videoplayback?expire=1&id=abc&itag=137 (decoded). + let expected_hex = + hex::encode("/companion/videoplayback?expire=1&id=abc&itag=137".as_bytes()); + assert!( + out.contains(&format!("/api/stream-proxy/v/VIDID/seg/{expected_hex}?grant=v4.local.TOK")), + "got: {out}" + ); + assert!(out.contains("seg/"), "rewrote BaseURL"); + // No raw unescaped `&` may leak into MPD content; all are `&`. + assert!( + !out.replace("&", "").contains('&'), + "every & must be XML-escaped: {out}" + ); + assert!(!out.contains("/companion/videoplayback?expire"), "original path replaced"); + // Structure preserved. + assert!(out.contains("")); + assert!(out.contains("")); + } + + #[test] + fn rewrite_dash_leaves_foreign_baseurls() { + let mpd = "https://googlevideo.com/keep"; + let out = rewrite_dash_manifest(mpd, "ID", "TOK"); + assert_eq!(out, mpd, "non-instance BaseURL left untouched"); + } + + #[test] + fn rewrite_dash_roundtrips_to_seg_path() { + // The emitted hex must decode (after un-escaping) to the instance path so + // the seg/{hex} branch resolves it against the origin via join_instance. + let mpd = "/companion/videoplayback?id=z"; + let out = rewrite_dash_manifest(mpd, "ID", "TOK"); + let hex_part = out + .split("seg/") + .nth(1) + .and_then(|s| s.split('?').next()) + .unwrap(); + let decoded = String::from_utf8(hex::decode(hex_part).unwrap()).unwrap(); + assert_eq!(decoded, "/companion/videoplayback?id=z"); + assert_eq!( + join_instance("http://inv.local", &decoded), + "http://inv.local/companion/videoplayback?id=z" + ); + } +} diff --git a/stream-proxy/src/handlers/session.rs b/stream-proxy/src/handlers/session.rs index 300edc75..c5db8a67 100644 --- a/stream-proxy/src/handlers/session.rs +++ b/stream-proxy/src/handlers/session.rs @@ -1,17 +1,60 @@ +//! `/session` (Jellyfin-style) entry route. +//! +//! The browser-facing token is the PASETO v4.local grant (`?grant=`); there is +//! no stored session id and no HMAC. `POST /session` verifies a grant, then +//! returns a grant-bearing stream URL. `GET /stream?grant=...[&suffix=hex]` +//! verifies the grant on every hit and streams bytes through, holding the +//! service credential server-side. +//! +//! TRANSITION NOTE: the existing adapter handoff still supplies the upstream URL +//! + auth headers inline (pre-grant adapter shape). For that path the proxy +//! registers the inline cred under a `inline:` backend keyed off the +//! grant's resource_ref, so the cred is held server-side (never in the browser +//! URL) while the full adapter migration to backend-resolved held creds lands in +//! the adapter-build phase. Native held-cred resolution (no inline registry) is +//! used by the Invidious `/v/...` route and is the target shape for Jellyfin too. + use crate::cache; use crate::handlers::hls::rewrite_manifest; -use crate::proxy::{ - cached_or_stream, full_body, stream_upstream_response, BoxError, HTTP_CLIENT, -}; -use crate::session::{self as session_store, AdapterKind, Session}; +use crate::proxy::{cached_or_stream, full_body, stream_upstream_response, BoxError, HTTP_CLIENT}; +use crate::session::{self, AdapterKind, HeldCred, VerifyError}; +use dashmap::DashMap; use http_body_util::{combinators::BoxBody, BodyExt}; use hyper::body::{Bytes, Incoming}; use hyper::{Request, Response, StatusCode}; use serde::Deserialize; use std::collections::HashMap; +use std::sync::LazyLock; + +/// Inline-cred registry for the back-compat adapter handoff. Keyed by a stable +/// digest of the grant's resource_ref. Stateless held-cred resolution (the +/// `/v/...` route) does NOT use this; it reads the env-injected table. +static INLINE_CREDS: LazyLock> = LazyLock::new(DashMap::new); + +#[derive(Clone)] +struct InlineSession { + upstream_url: String, + auth_headers: HashMap, + is_hls: bool, + url_prefix: String, + kind: AdapterKind, +} + +fn default_user() -> String { + "legacy".to_string() +} #[derive(Debug, Deserialize)] pub struct CreateSessionBody { + /// The PASETO v4.local grant minted by Node. Verified before anything else. + pub grant: String, + /// The Nexus user the grant was minted for. The /session registration is + /// verified against THIS identity — the same one the browser seam stamps as + /// X-Nexus-User at /stream time — so both paths agree. Defaults to "legacy" + /// for the pre-binding back-compat callers. + #[serde(default = "default_user")] + pub user_id: String, + /// Inline upstream URL (back-compat adapter shape; held server-side). pub upstream_url: String, #[serde(default)] pub auth_headers: HashMap, @@ -27,7 +70,20 @@ fn default_url_prefix_body() -> String { "/stream/".to_string() } -/// Handle `POST /session`. Reads JSON body, stores the session, returns signed ID. +/// Stable digest of an inline resource_ref → the registry key. Hex of a simple +/// FNV-1a hash; collision-resistant enough for an in-process transition map. +fn inline_key(resource_ref: &str) -> String { + let mut h: u64 = 0xcbf29ce484222325; + for b in resource_ref.as_bytes() { + h ^= *b as u64; + h = h.wrapping_mul(0x100000001b3); + } + format!("inline:{h:016x}") +} + +/// `POST /session`: verify the grant, register the inline cred, return a +/// grant-bearing stream URL. The grant — not a session id — is what the browser +/// carries back. pub async fn create(req: Request) -> Response> { let body_bytes = match req.into_body().collect().await { Ok(c) => c.to_bytes(), @@ -44,22 +100,35 @@ pub async fn create(req: Request) -> Response } }; - let session = Session { - upstream_url: parsed.upstream_url, - auth_headers: parsed.auth_headers, - is_hls: parsed.is_hls, - url_prefix: parsed.url_prefix, - kind: parsed.kind, - created_at: std::time::Instant::now(), + // Verify the registration grant against the SAME user the browser seam will + // stamp at /stream time (passed in the body by createStreamSession). This is + // what keeps /session and /stream agreeing on identity, so the legit user's + // grant verifies on both paths while a copy-pasted URL (no/other session) + // fails at the seam or the /stream X-Nexus-User check. + let expected_user = parsed.user_id.as_str(); + let grant = match session::verify_grant(&parsed.grant, expected_user, 0) { + Ok(g) => g, + Err(e) => return verify_reject("/session", e), }; - let id = session_store::create(session); - let sig = session_store::sign(&id); - let body = serde_json::json!({ - "session_id": id, - "signature": sig, - "stream_url": format!("/stream/{id}?sig={sig}"), - }) - .to_string(); + + // Register the inline cred under a key derived from the grant's resource_ref + // so /stream can recover it statelessly-enough (in-process) on each hit. + let key = inline_key(&grant.claims.resource_ref); + INLINE_CREDS.insert( + key.clone(), + InlineSession { + upstream_url: parsed.upstream_url, + auth_headers: parsed.auth_headers, + is_hls: parsed.is_hls, + url_prefix: parsed.url_prefix.clone(), + kind: parsed.kind, + }, + ); + + // The stream URL carries the grant (credential-free). The browser comes back + // to GET /stream?grant=. + let stream_url = format!("/stream?grant={}", urlencoding::encode(&parsed.grant)); + let body = serde_json::json!({ "stream_url": stream_url }).to_string(); Response::builder() .status(200) .header("content-type", "application/json") @@ -67,46 +136,78 @@ pub async fn create(req: Request) -> Response .unwrap() } -/// Handle `GET /stream/{session_id}[/{encoded_suffix}]?sig=...`. -/// -/// - Verifies the HMAC signature in the `sig` query parameter. -/// - Looks up the session. -/// - If `encoded_suffix` is absent, fetches the session's `upstream_url` directly. -/// If `is_hls` is true, parses the response as a manifest and rewrites it. -/// - If `encoded_suffix` is present, decodes it as hex, treats the result as an -/// opaque upstream path (produced by `hls::rewrite_uri`), and fetches a new -/// upstream request relative to the session's upstream origin. +/// `GET /stream?grant=[&suffix=]`: verify the grant, resolve the +/// inline cred, fetch upstream injecting the held auth, stream back. HLS +/// manifests are rewritten so child hops route back through the proxy with the +/// same grant. pub async fn stream(req: Request) -> Response> { let uri = req.uri().clone(); - let path = uri.path().to_string(); let query = uri.query().unwrap_or("").to_string(); - let mut parts = path.trim_start_matches("/stream/").splitn(2, '/'); - let session_id = match parts.next() { - Some(id) if !id.is_empty() => id.to_string(), - _ => return json_error(StatusCode::NOT_FOUND, "no session id"), + let grant_token = match query_param(&query, "grant") { + Some(g) => g, + None => return json_error(StatusCode::FORBIDDEN, "missing grant"), }; - let suffix = parts.next().map(|s| s.to_string()); + let suffix = query_param(&query, "suffix"); - let sig = query - .split('&') - .find_map(|p| p.strip_prefix("sig=")) - .unwrap_or("") - .to_string(); - if !session_store::verify(&session_id, &sig) { - return json_error(StatusCode::FORBIDDEN, "invalid signature"); - } + // Identity binding: the seam-stamped X-Nexus-User header IS the authority for + // reconstructing the implicit assertion. FAIL CLOSED if absent (adversarial + // review: the old "legacy" default collapsed cross-user binding). + let expected_user = match req + .headers() + .get("x-nexus-user") + .and_then(|v| v.to_str().ok()) + .filter(|s| !s.is_empty()) + { + Some(u) => u.to_string(), + None => return json_error(StatusCode::FORBIDDEN, "missing user identity"), + }; - let session = match session_store::get(&session_id) { - Some(s) => s, - None => return json_error(StatusCode::NOT_FOUND, "unknown session"), + let grant = match session::verify_grant(&grant_token, &expected_user, 0) { + Ok(g) => g, + Err(e) => return verify_reject("/stream", e), }; - // Figure out the upstream URL for this hit: - // - root hit → session.upstream_url (typically master.m3u8) - // - subpath hit → decode hex, resolve relative to upstream origin + // Resolve the held cred. Prefer the env held-cred table by backend; fall back + // to the inline registry for the back-compat path. + let (upstream_base, auth_headers, is_hls, url_prefix, kind) = + match session::held_cred(&grant.claims.backend) { + Some(HeldCred { + base_url, + auth_header_name, + auth_header_value, + }) => { + // Held-cred mode: resource_ref is the path under base_url; the + // grant is the only authority. HLS-ness is inferred from suffix + // or resource_ref extension. + let mut h = HashMap::new(); + h.insert(auth_header_name, auth_header_value); + let is_hls = grant.claims.resource_ref.ends_with(".m3u8") + || suffix.as_deref().map(is_m3u8).unwrap_or(false); + (base_url, h, is_hls, "/stream".to_string(), AdapterKind::Generic) + } + None => { + // Inline back-compat mode. + let key = inline_key(&grant.claims.resource_ref); + match INLINE_CREDS.get(&key) { + Some(s) => ( + s.upstream_url.clone(), + s.auth_headers.clone(), + s.is_hls, + s.url_prefix.clone(), + s.kind, + ), + None => { + // Unknown backend AND no inline session → fail closed. + return json_error(StatusCode::FORBIDDEN, "unknown backend"); + } + } + } + }; + + // Compute the upstream URL for this hit. let upstream_url = match &suffix { - None => session.upstream_url.clone(), + None => upstream_base.clone(), Some(encoded) => { let bytes = match hex::decode(encoded) { Ok(b) => b, @@ -116,34 +217,23 @@ pub async fn stream(req: Request) -> Response Ok(s) => s.to_string(), Err(_) => return json_error(StatusCode::BAD_REQUEST, "suffix not utf-8"), }; - resolve_relative(&session.upstream_url, &sub) + resolve_relative(&upstream_base, &sub) } }; - // Adapter-specific upstream URL rewriting. Plex's transcoder generates - // TS segments lazily; without waitForSegments=1 the server returns 404 - // for segments not yet emitted, so we enforce it on every hop. Other - // adapters get the URL as-is. - let upstream_url = adapter_rewrite_upstream_url(session.kind, upstream_url); - - // Non-HLS session: use cached_or_stream so segment-sized bodies hit the - // cache and trigger prefetch. proxy_stream stays for pure passthrough - // (e.g. ranged requests — cached_or_stream detects and delegates). - if !session.is_hls { - return cached_or_stream(&upstream_url, &session.auth_headers, req.headers()).await; - } + let upstream_url = adapter_rewrite_upstream_url(kind, upstream_url); - // HLS session: if the URL looks like a segment (not a manifest), take - // the cached path — most requests within an HLS session are segments, - // and caching them is the whole point of the proxy-side readahead. + if !is_hls { + return cached_or_stream(&upstream_url, &auth_headers, req.headers()).await; + } if !cache::is_manifest_url(&upstream_url) { - return cached_or_stream(&upstream_url, &session.auth_headers, req.headers()).await; + return cached_or_stream(&upstream_url, &auth_headers, req.headers()).await; } - // Manifest: fetch with auth, buffer, rewrite so segment URIs route - // back through our signed /stream/:id/:suffix path. + // Manifest: fetch with held auth, rewrite child URIs back through the proxy + // with this same grant. let mut req_builder = HTTP_CLIENT.get(&upstream_url); - for (k, v) in &session.auth_headers { + for (k, v) in &auth_headers { req_builder = req_builder.header(k, v); } if let Some(range) = req.headers().get("range") { @@ -168,15 +258,10 @@ pub async fn stream(req: Request) -> Response let is_manifest = content_type.starts_with("application/vnd.apple.mpegurl") || content_type.starts_with("application/x-mpegurl") || content_type.starts_with("audio/mpegurl") - || upstream_url.ends_with(".m3u8"); - + || upstream_url.contains(".m3u8"); if !is_manifest { - // URL looked like a manifest but server returned binary. Stream it - // through without caching — we can't rely on our cacheable heuristics - // for content that lied about its type. return stream_upstream_response(upstream).await; } - let body = match upstream.bytes().await { Ok(b) => b, Err(e) => { @@ -184,14 +269,14 @@ pub async fn stream(req: Request) -> Response return json_error(StatusCode::BAD_GATEWAY, "upstream body"); } }; - let sig = session_store::sign(&session_id); - let rewritten = match rewrite_manifest(&body, &session_id, &sig, &session.url_prefix, &upstream_url, session.kind) { - Ok(out) => out, - Err(e) => { - eprintln!("[stream-proxy] manifest rewrite error: {e}"); - return json_error(StatusCode::BAD_GATEWAY, "manifest rewrite"); - } - }; + let rewritten = + match rewrite_manifest(&body, &grant_token, &url_prefix, &upstream_url, kind) { + Ok(out) => out, + Err(e) => { + eprintln!("[stream-proxy] manifest rewrite error: {e}"); + return json_error(StatusCode::BAD_GATEWAY, "manifest rewrite"); + } + }; Response::builder() .status(status.as_u16()) .header("content-type", "application/vnd.apple.mpegurl") @@ -200,9 +285,25 @@ pub async fn stream(req: Request) -> Response .unwrap() } -/// Dispatch upstream-URL rewriting based on which adapter produced this -/// session. The default path is identity — only adapters with known quirks -/// get bespoke handling here, and those quirks stay contained to this file. +fn is_m3u8(s: &str) -> bool { + s.split('?').next().unwrap_or(s).ends_with(".m3u8") +} + +/// Map a verify failure to a 403 (or 502 for nothing — all map to 403). The +/// reason is logged, never surfaced. +fn verify_reject(route: &str, e: VerifyError) -> Response> { + eprintln!("[stream-proxy] {route} grant rejected: {e:?}"); + json_error(StatusCode::FORBIDDEN, "invalid grant") +} + +fn query_param(query: &str, name: &str) -> Option { + let prefix = format!("{name}="); + query + .split('&') + .find_map(|p| p.strip_prefix(&prefix)) + .map(|v| urlencoding::decode(v).map(|c| c.into_owned()).unwrap_or_else(|_| v.to_string())) +} + fn adapter_rewrite_upstream_url(kind: AdapterKind, url: String) -> String { match kind { AdapterKind::Plex => ensure_plex_wait_for_segments(url), @@ -210,10 +311,6 @@ fn adapter_rewrite_upstream_url(kind: AdapterKind, url: String) -> String { } } -/// Append `waitForSegments=1` to any URL under the Plex -/// `/video/:/transcode/universal/` path, unless already present. Plex's -/// transcoder generates segments lazily and returns 404 for not-yet-emitted -/// ones; this flag makes the server block until the segment exists. fn ensure_plex_wait_for_segments(url: String) -> String { if !url.contains("/transcode/universal/") { return url; @@ -237,39 +334,31 @@ fn json_error(status: StatusCode, msg: &str) -> Response String { - if sub.starts_with("http://") || sub.starts_with("https://") { - return sub.to_string(); - } - - // Strip any query string from the base before computing the parent directory. let base_no_query = base.split('?').next().unwrap_or(base); - let origin_end = base_no_query .find("://") .and_then(|i| base_no_query[i + 3..].find('/').map(|j| i + 3 + j)) .unwrap_or(base_no_query.len()); let origin = &base_no_query[..origin_end]; - + // SECURITY (adversarial review): `sub` comes from the (attacker-controlled) + // hex `suffix`. NEVER trust an absolute host from it — re-anchor its path to + // the verified UPSTREAM origin so a crafted suffix can't SSRF / exfil the + // injected held cred to another host. (Jellyfin HLS child segments are + // same-host relative, so this is transparent for legitimate playback.) + if sub.starts_with("http://") || sub.starts_with("https://") { + let path = sub + .splitn(2, "://") + .nth(1) + .and_then(|hostpath| hostpath.find('/').map(|i| &hostpath[i..])) + .unwrap_or("/"); + return format!("{origin}{path}"); + } if sub.starts_with('/') { return format!("{origin}{sub}"); } - - // Path-relative: take the base's directory (everything up to and including - // the last `/`) and append the sub verbatim. The sub may contain a query - // string — that's preserved as part of the returned URL. let dir_end = base_no_query.rfind('/').unwrap_or(base_no_query.len()); let dir = &base_no_query[..=dir_end.min(base_no_query.len() - 1)]; format!("{dir}{sub}") @@ -288,31 +377,19 @@ mod tests { ), "http://jellyfin.local/Videos/abc/main.m3u8" ); - } - - #[test] - fn resolve_relative_handles_https_with_port() { + // SECURITY: an absolute URL to a DIFFERENT host must be re-anchored to the + // upstream origin (no SSRF / cred exfil to an attacker-chosen host). assert_eq!( resolve_relative( - "https://jf.example.com:8096/path?x=1", - "/Videos/abc/segment.ts" + "http://jellyfin.local/Videos/abc/master.m3u8", + "http://169.254.169.254/latest/meta-data/" ), - "https://jf.example.com:8096/Videos/abc/segment.ts" - ); - } - - #[test] - fn resolve_relative_passes_through_absolute_upstream_urls() { - assert_eq!( - resolve_relative("http://a", "http://b/x"), - "http://b/x" + "http://jellyfin.local/latest/meta-data/" ); } #[test] fn resolve_relative_handles_path_relative_urls() { - // Jellyfin emits variant URIs like `live.m3u8?...` in master playlists. - // These are relative to the master's parent directory. assert_eq!( resolve_relative( "http://jellyfin.local/Videos/abc/master.m3u8", @@ -323,24 +400,18 @@ mod tests { } #[test] - fn resolve_relative_strips_base_query_before_resolving() { - // Base URL may carry its own query — it should not leak into the - // resolved path. - assert_eq!( - resolve_relative( - "http://jf.local/Videos/abc/master.m3u8?DeviceId=nexus", - "live.m3u8" - ), - "http://jf.local/Videos/abc/live.m3u8" - ); + fn resolve_relative_reanchors_absolute_urls_to_upstream_origin() { + // SECURITY: a cross-host absolute URL in the (attacker-controlled) suffix + // is re-anchored to the upstream origin — it must NOT pass through to an + // arbitrary host (was an SSRF / held-cred-exfil hole). + assert_eq!(resolve_relative("http://a", "http://b/x"), "http://a/x"); } #[test] - fn ensure_plex_wait_for_segments_appends_to_plex_urls() { + fn ensure_plex_wait_for_segments_appends() { assert_eq!( ensure_plex_wait_for_segments( - "http://plex.local/video/:/transcode/universal/start.m3u8?path=/lib/123" - .to_string() + "http://plex.local/video/:/transcode/universal/start.m3u8?path=/lib/123".to_string() ), "http://plex.local/video/:/transcode/universal/start.m3u8?path=/lib/123&waitForSegments=1" ); @@ -348,24 +419,26 @@ mod tests { #[test] fn ensure_plex_wait_for_segments_is_idempotent() { - let u = "http://plex.local/video/:/transcode/universal/00001.ts?waitForSegments=1" - .to_string(); + let u = "http://plex.local/video/:/transcode/universal/0.ts?waitForSegments=1".to_string(); assert_eq!(ensure_plex_wait_for_segments(u.clone()), u); } #[test] - fn ensure_plex_wait_for_segments_leaves_jellyfin_alone() { - let u = "http://jf.local/Videos/abc/hls1/main/0.ts".to_string(); - assert_eq!(ensure_plex_wait_for_segments(u.clone()), u); + fn inline_key_is_stable() { + assert_eq!(inline_key("abc"), inline_key("abc")); + assert_ne!(inline_key("abc"), inline_key("abd")); } #[test] - fn ensure_plex_wait_for_segments_handles_no_query() { + fn query_param_extracts_and_decodes() { assert_eq!( - ensure_plex_wait_for_segments( - "http://plex.local/video/:/transcode/universal/session/abc/base/0.ts".to_string() - ), - "http://plex.local/video/:/transcode/universal/session/abc/base/0.ts?waitForSegments=1" + query_param("grant=v4.local.abc&suffix=00ff", "grant").as_deref(), + Some("v4.local.abc") + ); + assert_eq!( + query_param("grant=a%20b", "grant").as_deref(), + Some("a b") ); + assert_eq!(query_param("x=1", "grant"), None); } } diff --git a/stream-proxy/src/main.rs b/stream-proxy/src/main.rs index 66b914ef..bafee8f6 100644 --- a/stream-proxy/src/main.rs +++ b/stream-proxy/src/main.rs @@ -4,28 +4,33 @@ use hyper::server::conn::http1; use hyper::service::service_fn; use hyper::{Request, Response}; use hyper_util::rt::TokioIo; +use socket2::{Domain, Socket, Type}; use std::convert::Infallible; use std::env; use std::net::SocketAddr; -use std::sync::Arc; -use socket2::{Domain, Socket, Type}; +use std::sync::{Arc, LazyLock}; + +/// Seam↔proxy shared secret (defense-in-depth, adversarial review). Only the +/// SvelteKit seam knows NEXUS_PROXY_AUTH; every request except /healthz and CORS +/// preflight must present it, so a process that gains loopback access can't forge +/// `x-nexus-user`. Empty/unset disables the check (back-compat). +static PROXY_AUTH: LazyLock> = + LazyLock::new(|| env::var("NEXUS_PROXY_AUTH").ok().filter(|s| !s.is_empty())); use tokio::io::AsyncReadExt; use tokio::net::TcpListener; -type BoxError = Box; - // ── Request handler ──────────────────────────────────────────────────────── async fn handle( req: Request, invidious_url: Arc, -) -> Result>, Infallible> { - // CORS preflight +) -> Result>, Infallible> { + // CORS preflight (loopback-only, but the SvelteKit reverse-proxy may add it). if req.method() == hyper::Method::OPTIONS { return Ok(Response::builder() .status(204) .header("Access-Control-Allow-Origin", "*") - .header("Access-Control-Allow-Headers", "Range, Content-Type") + .header("Access-Control-Allow-Headers", "Range, Content-Type, X-Nexus-User") .header("Access-Control-Allow-Methods", "GET, POST, HEAD, OPTIONS") .body(nexus_stream_proxy::proxy::empty_body()) .unwrap()); @@ -35,19 +40,60 @@ async fn handle( let query = req.uri().query().unwrap_or("").to_string(); let method = req.method().clone(); - // New session-based routes + // Health check — load-bearing for the seam trust (Node verifies the child is up). + if path == "/healthz" { + return Ok(Response::builder() + .status(200) + .header("content-type", "text/plain") + .body(nexus_stream_proxy::proxy::full_body("ok")) + .unwrap()); + } + + // Seam↔proxy shared-secret gate: reject anything that didn't come through the + // SvelteKit seam (which alone holds NEXUS_PROXY_AUTH and stamps x-nexus-user), + // so a process that gains loopback access can't forge identity. + if let Some(expected) = PROXY_AUTH.as_ref() { + let ok = req + .headers() + .get("x-nexus-proxy-auth") + .and_then(|v| v.to_str().ok()) + == Some(expected.as_str()); + if !ok { + return Ok(Response::builder() + .status(403) + .body(nexus_stream_proxy::proxy::full_body("forbidden")) + .unwrap()); + } + } + + // Jellyfin-style entry: POST /session (verify grant, register inline cred, + // return grant-bearing stream URL) + GET /stream?grant=... if method == hyper::Method::POST && path == "/session" { return Ok(nexus_stream_proxy::handlers::session::create(req).await); } if method == hyper::Method::GET - && path.starts_with("/stream/") - && query.split('&').any(|p| p.starts_with("sig=")) + && path == "/stream" + && query.split('&').any(|p| p.starts_with("grant=")) { return Ok(nexus_stream_proxy::handlers::session::stream(req).await); } - // Legacy Invidious-specific routes (/stats, /proxy?url=..., legacy /stream/...) - Ok(nexus_stream_proxy::handlers::invidious::handle(req, invidious_url).await) + // Invidious-style entry: GET /v/{id}/...?grant= + if method == hyper::Method::GET && path.starts_with("/v/") { + return Ok(nexus_stream_proxy::handlers::invidious::handle_v(req, invidious_url).await); + } + + // SECURITY (adversarial review, belt-and-suspenders): the legacy + // `invidious::handle` routes (/proxy?url=, /stats, /stream/{id}) are an + // open-proxy SSRF + info-leak surface and are NOT used by the v2 paths + // (/session, /stream, /v/). Nothing legitimate reaches the proxy except via + // the seam, which allowlists only /stream and /v. Refuse anything else + // outright so the dormant routes can't be reawakened by a future bypass. + let _ = &invidious_url; + Ok(Response::builder() + .status(404) + .body(nexus_stream_proxy::proxy::full_body("not found")) + .unwrap()) } #[tokio::main] @@ -57,16 +103,29 @@ async fn main() { .and_then(|s| s.parse().ok()) .unwrap_or(3939); + // Bind loopback only by default — the proxy is reached only via the + // SvelteKit reverse-proxy on the same host (the seam). Never expose it. + let bind_ip = env::var("STREAM_BIND").unwrap_or_else(|_| "127.0.0.1".to_string()); + let octets: Vec = bind_ip + .split('.') + .filter_map(|o| o.parse().ok()) + .collect(); + let ip = if octets.len() == 4 { + [octets[0], octets[1], octets[2], octets[3]] + } else { + eprintln!("[stream-proxy] invalid STREAM_BIND '{bind_ip}', falling back to 127.0.0.1"); + [127, 0, 0, 1] + }; + let invidious_url = env::var("INVIDIOUS_URL").unwrap_or_else(|_| { eprintln!("[stream-proxy] INVIDIOUS_URL not set, using http://localhost:3000"); "http://localhost:3000".to_string() }); let invidious_url = Arc::new(invidious_url); - let addr = SocketAddr::from(([0, 0, 0, 0], port)); + let addr = SocketAddr::from((ip, port)); - // Use socket2 to set SO_REUSEADDR before binding, so restarts don't fail - // with "Address already in use" while the OS holds the port in TIME_WAIT. + // SO_REUSEADDR so restarts don't fail while the OS holds the port in TIME_WAIT. let socket = Socket::new(Domain::IPV4, Type::STREAM, None).unwrap(); socket.set_reuse_address(true).unwrap(); socket.set_nonblocking(true).unwrap(); @@ -74,13 +133,12 @@ async fn main() { socket.listen(1024).unwrap(); let listener = TcpListener::from_std(socket.into()).unwrap(); - println!("[stream-proxy] Rust video proxy on port {port} -> {invidious_url}"); + println!("[stream-proxy] Rust video proxy on {bind_ip}:{port} -> {invidious_url}"); // Exit when parent Node process dies (stdin EOF). tokio::spawn(async { let mut stdin = tokio::io::stdin(); let mut buf = [0u8; 1]; - // read blocks until EOF (parent closed pipe / exited) let _ = stdin.read(&mut buf).await; eprintln!("[stream-proxy] Parent process gone, shutting down"); std::process::exit(0); @@ -90,7 +148,6 @@ async fn main() { let (stream, _) = listener.accept().await.unwrap(); let io = TokioIo::new(stream); let inv = invidious_url.clone(); - tokio::task::spawn(async move { if let Err(err) = http1::Builder::new() .serve_connection(io, service_fn(move |req| handle(req, inv.clone()))) diff --git a/stream-proxy/src/proxy.rs b/stream-proxy/src/proxy.rs index 71b896c7..f9f3f518 100644 --- a/stream-proxy/src/proxy.rs +++ b/stream-proxy/src/proxy.rs @@ -11,7 +11,21 @@ use std::time::Duration; pub type BoxError = Box; pub static HTTP_CLIENT: LazyLock = LazyLock::new(|| { + // Ask upstreams NOT to compress. reqwest is built without the gzip/br + // features, so it would forward a compressed body verbatim — but downstream + // (undici in the SvelteKit seam) strips `content-encoding` off the headers + // while handing us the still-compressed `.body` stream, so the browser ends + // up with gzip bytes labelled text and parses garbage (caught: gzipped + // WebVTT → 0 cues). Forcing identity keeps the whole chain uncompressed. + // Media containers (mp4/m4s/ts) are already compressed, so this costs + // nothing on the hot streaming path. + let mut default_headers = reqwest::header::HeaderMap::new(); + default_headers.insert( + reqwest::header::ACCEPT_ENCODING, + reqwest::header::HeaderValue::from_static("identity"), + ); Client::builder() + .default_headers(default_headers) .redirect(reqwest::redirect::Policy::limited(5)) .timeout(Duration::from_secs(60)) // Raise from 32 — segment fanout + prefetch can saturate a smaller pool @@ -37,7 +51,11 @@ const FORWARDED_REQUEST_HEADERS: &[&str] = &[ "range", "if-none-match", "if-modified-since", - "accept-encoding", + // NOT accept-encoding: the proxy negotiates encoding itself (identity, via + // HTTP_CLIENT's default header). Forwarding the browser's `gzip` would make + // the upstream compress, but the downstream seam (undici) strips + // `content-encoding` while handing us the raw compressed `.body`, so the + // browser would receive gzip bytes labelled plain. Keep the chain identity. ]; /// Headers we forward from upstream → client. Any other upstream header is dropped. diff --git a/stream-proxy/src/session.rs b/stream-proxy/src/session.rs index 852c11ad..849bdd04 100644 --- a/stream-proxy/src/session.rs +++ b/stream-proxy/src/session.rs @@ -1,32 +1,39 @@ -use dashmap::DashMap; -use hmac::{Hmac, Mac}; -use serde::{Deserialize, Serialize}; -use sha2::Sha256; +//! Grant verification + held-credential resolution — the shared spine both +//! entry routes (`/session` Jellyfin-style, `/v/{id}/...?grant=` Invidious-style) +//! stream through. +//! +//! The token is a PASETO **v4.local** grant. It carries NO credential: only a +//! sealed `{backend, resource_ref, allowed_hops, exp, gen}` payload plus +//! `{user_id, hop_index, gen}` implicit assertions authenticated into the AEAD +//! tag. The proxy HOLDS the per-backend service credential server-side (the +//! `HeldCredTable`, injected by the Node supervisor via env). On a request the +//! flow is: +//! +//! 1. verify the grant (tag + AAD-free + native `exp`) under the current key, +//! falling back to the previous key for zero-downtime rotation; +//! 2. assert the caller-presented `user_id` matches the implicit assertion the +//! grant was minted with (copy-paste / confused-deputy defense); +//! 3. resolve the HELD cred for `backend` (unknown backend → fail closed); +//! 4. (caller) resolve the upstream URL and fetch with the held auth header. +//! +//! There is no session store and no per-process secret. The grant IS the +//! session — stateless and restart-proof. The key comes from the env +//! (`NEXUS_STREAM_PASETO_KEY`, PASERK `k4.local`), derived once by Node and +//! never re-derived here. + +use core::convert::TryFrom; +use pasetors::keys::SymmetricKey; +use pasetors::token::UntrustedToken; +use pasetors::version4::{LocalToken, V4}; +use pasetors::Local; +use serde::Deserialize; use std::collections::HashMap; use std::sync::LazyLock; -use std::time::{Duration, Instant}; - -type HmacSha256 = Hmac; - -/// Process-lifetime HMAC secret. Regenerated on every start — sessions don't -/// need to survive restarts because the Node supervisor restarts us anyway. -static SECRET: LazyLock<[u8; 32]> = LazyLock::new(|| { - let mut buf = [0u8; 32]; - use rand::RngCore; - rand::thread_rng().fill_bytes(&mut buf); - buf -}); - -/// Sessions are keyed by 16-char hex ID, store the upstream URL + auth headers. -static SESSIONS: LazyLock> = LazyLock::new(DashMap::new); +use std::time::{SystemTime, UNIX_EPOCH}; -const SESSION_TTL: Duration = Duration::from_secs(6 * 60 * 60); // 6h - -/// Which adapter produced this session. The proxy uses this to dispatch -/// server-specific workarounds (Plex's lazy segments, live-style manifests) -/// instead of applying them to every HLS session unconditionally. Anything -/// the proxy doesn't specifically handle is treated as `Generic`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +/// Which adapter produced this grant. Drives per-server quirks in the HLS +/// rewriter and upstream fetch path. Anything unrecognized = `Generic`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum AdapterKind { Plex, @@ -35,128 +42,357 @@ pub enum AdapterKind { Generic, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Session { - pub upstream_url: String, - pub auth_headers: HashMap, - /// If true, HLS manifests are parsed and rewritten as they pass through. - #[serde(default)] - pub is_hls: bool, - /// Path prefix the HLS rewriter uses when emitting proxy URLs. - /// Defaults to "/stream/" for direct Rust access; Node reverse-proxy - /// deployments pass "/api/stream-proxy/" when creating the session. - #[serde(default = "default_url_prefix")] - pub url_prefix: String, - /// Adapter that produced this session. Drives per-server quirks in the - /// HLS rewriter and the upstream fetch path. +/// A held service credential for one backend: the upstream base + the auth +/// header the proxy injects upstream. Never travels to the browser. +#[derive(Debug, Clone, Deserialize)] +pub struct HeldCred { + pub base_url: String, + pub auth_header_name: String, + pub auth_header_value: String, +} + +/// `backend id -> HeldCred`. Injected by the Node supervisor as +/// `NEXUS_STREAM_HELD_CREDS` (JSON). Resolved fail-closed: an unknown backend +/// yields no cred and the request is rejected. +pub type HeldCredTable = HashMap; + +/// The grant payload claims (decrypted from the PASETO token body). +#[derive(Debug, Clone, Deserialize)] +pub struct GrantClaims { + pub backend: String, + pub resource_ref: String, #[serde(default)] - pub kind: AdapterKind, - /// Monotonic creation timestamp. Not serialized — set to `Instant::now()` when - /// the struct is deserialized from the POST body. Used only for TTL enforcement. - #[serde(skip, default = "Instant::now")] - pub created_at: Instant, -} - -fn default_url_prefix() -> String { - "/stream/".to_string() -} - -pub fn create(session: Session) -> String { - // 16 random hex chars = 64 bits of entropy, plenty for process-lifetime IDs. - let mut id_bytes = [0u8; 8]; - use rand::RngCore; - rand::thread_rng().fill_bytes(&mut id_bytes); - let id = hex::encode(id_bytes); - SESSIONS.insert(id.clone(), session); - id -} - -pub fn sign(id: &str) -> String { - let mut mac = HmacSha256::new_from_slice(&*SECRET).expect("valid HMAC key"); - mac.update(id.as_bytes()); - hex::encode(mac.finalize().into_bytes()) -} - -pub fn verify(id: &str, signature: &str) -> bool { - let expected = sign(id); - // Constant-time comparison — subtle::ConstantTimeEq would be better but - // `hex::encode` already returns a `String` so we get timing behavior that - // depends on the length check. Acceptable for 64-char hex digests. - expected.len() == signature.len() - && expected - .bytes() - .zip(signature.bytes()) - .fold(0u8, |acc, (a, b)| acc | (a ^ b)) - == 0 -} - -pub fn get(id: &str) -> Option { - let entry = SESSIONS.get(id)?; - if entry.created_at.elapsed() > SESSION_TTL { - drop(entry); - SESSIONS.remove(id); + pub allowed_hops: String, + /// RFC3339 string per the v4.local exp claim. Validated natively below. + pub exp: String, + // `gen` is a reserved keyword in Rust 2024 — store as `generation`, map the + // wire claim name `gen` via serde. + #[serde(default, rename = "gen")] + pub generation: u64, +} + +/// The verified grant: claims + the implicit assertion fields the token was +/// authenticated against. `user_id`/`hop_index`/`gen` come from the implicit +/// assertion the proxy reconstructed and the tag confirmed. +#[derive(Debug, Clone)] +pub struct VerifiedGrant { + pub claims: GrantClaims, + pub user_id: String, + pub hop_index: u64, + pub generation: u64, +} + +/// Why a grant failed to verify. Callers map all of these to a 403 — the +/// variant is for logging only, never surfaced to the client. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VerifyError { + /// AEAD tag / structural / decrypt failure under every configured key. + BadToken, + /// Decrypted fine but the body wasn't a well-formed grant. + BadClaims, + /// Native `exp` is in the past. + Expired, +} + +// ── Key material ──────────────────────────────────────────────────────────── + +/// Current + optional previous PASERK k4.local key, parsed once from env. We +/// verify against current, then previous (zero-downtime rotation). Keys are the +/// raw 32-byte v4.local symmetric keys parsed from the PASERK string Node +/// injected; never re-derived here. +struct Keys { + current: Option>, + previous: Option>, +} + +static KEYS: LazyLock = LazyLock::new(|| { + let current = std::env::var("NEXUS_STREAM_PASETO_KEY") + .ok() + .and_then(|s| parse_paserk(&s)); + if current.is_none() { + eprintln!( + "[stream-proxy] NEXUS_STREAM_PASETO_KEY missing or invalid — all grant verification will fail closed" + ); + } + let previous = std::env::var("NEXUS_STREAM_PASETO_KEY_PREVIOUS") + .ok() + .and_then(|s| parse_paserk(&s)); + Keys { current, previous } +}); + +fn parse_paserk(s: &str) -> Option> { + SymmetricKey::::try_from(s.trim()).ok() +} + +// ── Held-cred table ───────────────────────────────────────────────────────── + +static HELD_CREDS: LazyLock = LazyLock::new(|| { + match std::env::var("NEXUS_STREAM_HELD_CREDS") { + Ok(raw) if !raw.trim().is_empty() => serde_json::from_str(&raw).unwrap_or_else(|e| { + eprintln!("[stream-proxy] NEXUS_STREAM_HELD_CREDS parse error: {e} — starting with empty table"); + HashMap::new() + }), + _ => HashMap::new(), + } +}); + +/// Resolve the held cred for a backend. `None` ⇒ fail closed (403). +pub fn held_cred(backend: &str) -> Option { + HELD_CREDS.get(backend).cloned() +} + +// ── Implicit assertion ────────────────────────────────────────────────────── + +/// Reconstruct the implicit-assertion bytes EXACTLY as the Node mint side +/// serialized them (`stream-grant.ts::serializeImplicitAssertion`). PASETO +/// authenticates these raw bytes into the tag — any divergence (key order, +/// spacing, escaping) makes verification fail, so this must mirror Node +/// byte-for-byte: `{"user_id":,"hop_index":,"gen":}`. +pub fn implicit_assertion_bytes(user_id: &str, hop_index: u64, generation: u64) -> Vec { + // serde_json::to_string on a String yields the same escaping rules Node's + // JSON.stringify uses for a string, so user_id is escaped identically. + let uid = serde_json::to_string(user_id).unwrap_or_else(|_| "\"\"".to_string()); + format!("{{\"user_id\":{uid},\"hop_index\":{hop_index},\"gen\":{generation}}}").into_bytes() +} + +// ── Verification ──────────────────────────────────────────────────────────── + +/// Verify a grant token and bind it to the caller-presented `user_id`, +/// defaulting `gen` to 0 (the mint default). +/// +/// PASETO v4.local authenticates the implicit assertion into the AEAD tag, so +/// the exact `{user_id, hop_index, gen}` bytes must be known BEFORE decrypting — +/// we can't read claims first. The Nexus seam always knows the user's current +/// `gen`, so the real entry point is [`verify_grant_with_gen`], which takes it +/// explicitly. This convenience wrapper covers the common `gen == 0` case (tests +/// + the back-compat `/session` inline path) without the seam threading gen. +pub fn verify_grant( + token: &str, + expected_user_id: &str, + hop_index: u64, +) -> Result { + verify_grant_with_gen(token, expected_user_id, hop_index, 0) +} + +/// Verify a grant given the exact `gen` the seam expects for this user. This is +/// the real entry point: the implicit assertion is reconstructed from +/// `(expected_user_id, hop_index, gen)` and authenticated by the tag, so a +/// wrong user, wrong hop, or stale gen all fail as `BadToken`. +pub fn verify_grant_with_gen( + token: &str, + expected_user_id: &str, + hop_index: u64, + generation: u64, +) -> Result { + let implicit = implicit_assertion_bytes(expected_user_id, hop_index, generation); + let untrusted = + UntrustedToken::::try_from(token).map_err(|_| VerifyError::BadToken)?; + // Try current key, then previous (rotation window). + let trusted = try_decrypt(&untrusted, &implicit).ok_or(VerifyError::BadToken)?; + finish_verify(trusted, expected_user_id, hop_index, generation) +} + +/// Verify against an EXPLICIT key (the golden-vector test + any caller that +/// doesn't want the env-loaded keys). Same security properties as +/// [`verify_grant_with_gen`]: the implicit assertion is reconstructed from +/// `(expected_user_id, hop_index, generation)` and authenticated by the tag. +pub fn verify_grant_with_key( + token: &str, + key: &SymmetricKey, + expected_user_id: &str, + hop_index: u64, + generation: u64, +) -> Result { + let implicit = implicit_assertion_bytes(expected_user_id, hop_index, generation); + let untrusted = + UntrustedToken::::try_from(token).map_err(|_| VerifyError::BadToken)?; + let trusted = + LocalToken::decrypt(key, &untrusted, None, Some(&implicit)).map_err(|_| VerifyError::BadToken)?; + finish_verify(trusted, expected_user_id, hop_index, generation) +} + +fn finish_verify( + trusted: pasetors::token::TrustedToken, + expected_user_id: &str, + hop_index: u64, + generation: u64, +) -> Result { + let claims: GrantClaims = + serde_json::from_str(trusted.payload()).map_err(|_| VerifyError::BadClaims)?; + // Native exp check (RFC3339 → epoch). + if is_expired(&claims.exp) { + return Err(VerifyError::Expired); + } + // The tag already proved the token was minted for `expected_user_id` (it's + // in the implicit assertion). user_id lives only in the assertion, so we + // echo the expected one — a mismatch would have failed as BadToken above. + Ok(VerifiedGrant { + claims: GrantClaims { + generation, + ..claims + }, + user_id: expected_user_id.to_string(), + hop_index, + generation, + }) +} + +/// Parse a PASERK `k4.local` key string into a usable symmetric key. Exposed for +/// the golden-vector test (which supplies the fixed fixture key directly). +pub fn parse_local_key(paserk: &str) -> Option> { + parse_paserk(paserk) +} + +fn try_decrypt( + untrusted: &UntrustedToken, + implicit: &[u8], +) -> Option { + // pasetors footer handling: we minted with an optional footer (kid). When a + // footer is present in the token it's validated structurally; we pass `None` + // so it's accepted-but-not-compared. The implicit assertion is the security + // binding. + if let Some(k) = &KEYS.current { + if let Ok(t) = LocalToken::decrypt(k, untrusted, None, Some(implicit)) { + return Some(t); + } + } + if let Some(k) = &KEYS.previous { + if let Ok(t) = LocalToken::decrypt(k, untrusted, None, Some(implicit)) { + return Some(t); + } + } + None +} + +fn is_expired(exp_rfc3339: &str) -> bool { + match parse_rfc3339_epoch(exp_rfc3339) { + Some(exp) => now_epoch() >= exp, + // Unparseable exp = treat as expired (fail closed). + None => true, + } +} + +fn now_epoch() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(u64::MAX) +} + +/// Minimal RFC3339 → unix-epoch-seconds parser. Handles the forms paseto-ts and +/// pasetors emit: `YYYY-MM-DDTHH:MM:SS[.fff]Z` and `±HH:MM` offsets. Returns +/// `None` on anything it can't parse (caller treats that as expired). +fn parse_rfc3339_epoch(s: &str) -> Option { + let b = s.as_bytes(); + if b.len() < 19 { return None; } - Some(entry.clone()) + let year: i64 = s.get(0..4)?.parse().ok()?; + let month: i64 = s.get(5..7)?.parse().ok()?; + let day: i64 = s.get(8..10)?.parse().ok()?; + let hour: i64 = s.get(11..13)?.parse().ok()?; + let min: i64 = s.get(14..16)?.parse().ok()?; + let sec: i64 = s.get(17..19)?.parse().ok()?; + + // Optional fractional seconds, then a timezone designator. + let mut idx = 19; + if b.get(idx) == Some(&b'.') { + idx += 1; + while idx < b.len() && b[idx].is_ascii_digit() { + idx += 1; + } + } + // Timezone offset (we honor it so non-UTC exp strings still compare right). + let mut offset_secs: i64 = 0; + match b.get(idx) { + Some(&b'Z') | Some(&b'z') | None => {} + Some(&b'+') | Some(&b'-') => { + let sign = if b[idx] == b'-' { -1 } else { 1 }; + let oh: i64 = s.get(idx + 1..idx + 3)?.parse().ok()?; + let om: i64 = s.get(idx + 4..idx + 6)?.parse().ok()?; + offset_secs = sign * (oh * 3600 + om * 60); + } + _ => return None, + } + + let days = days_from_civil(year, month, day); + let epoch = days * 86400 + hour * 3600 + min * 60 + sec - offset_secs; + if epoch < 0 { + None + } else { + Some(epoch as u64) + } } -pub fn remove(id: &str) { - SESSIONS.remove(id); +/// Days since the Unix epoch for a civil date (Howard Hinnant's algorithm). +fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146097 + doe - 719468 } #[cfg(test)] mod tests { use super::*; - fn fresh_session() -> Session { - Session { - upstream_url: "http://example.com/master.m3u8".to_string(), - auth_headers: HashMap::from([("X-Api-Key".to_string(), "secret".to_string())]), - is_hls: true, - url_prefix: "/stream/".to_string(), - kind: AdapterKind::Generic, - created_at: Instant::now(), - } + #[test] + fn rfc3339_parses_utc_z() { + // 2020-01-01T00:00:00Z = 1577836800 + assert_eq!(parse_rfc3339_epoch("2020-01-01T00:00:00Z"), Some(1577836800)); } #[test] - fn create_returns_unique_hex_ids() { - let a = create(fresh_session()); - let b = create(fresh_session()); - assert_ne!(a, b, "IDs must be unique"); - assert_eq!(a.len(), 16, "ID is 16 hex chars"); - assert!(a.chars().all(|c| c.is_ascii_hexdigit())); + fn rfc3339_parses_millis_z() { + assert_eq!( + parse_rfc3339_epoch("2020-01-01T00:00:00.000Z"), + Some(1577836800) + ); } #[test] - fn sign_and_verify_round_trip() { - let id = create(fresh_session()); - let sig = sign(&id); - assert!(verify(&id, &sig), "valid signature verifies"); - assert!(!verify(&id, "bogus"), "bogus signature rejected"); - assert!( - !verify(&id, &sig.replace('a', "b")), - "tampered signature rejected" + fn rfc3339_honors_offset() { + // 2020-01-01T01:00:00+01:00 == 2020-01-01T00:00:00Z + assert_eq!( + parse_rfc3339_epoch("2020-01-01T01:00:00+01:00"), + Some(1577836800) ); } #[test] - fn get_returns_stored_session() { - let id = create(fresh_session()); - let s = get(&id).expect("session present"); - assert_eq!(s.upstream_url, "http://example.com/master.m3u8"); - assert_eq!(s.auth_headers.get("X-Api-Key"), Some(&"secret".to_string())); + fn rfc3339_rejects_garbage() { + assert_eq!(parse_rfc3339_epoch("not-a-date"), None); + assert_eq!(parse_rfc3339_epoch(""), None); + } + + #[test] + fn implicit_assertion_matches_node_serialization() { + // Must mirror stream-grant.ts::serializeImplicitAssertion exactly. + let bytes = implicit_assertion_bytes("user-001", 0, 7); + assert_eq!( + std::str::from_utf8(&bytes).unwrap(), + r#"{"user_id":"user-001","hop_index":0,"gen":7}"# + ); + } + + #[test] + fn implicit_assertion_escapes_user_id() { + let bytes = implicit_assertion_bytes("a\"b", 1, 2); + assert_eq!( + std::str::from_utf8(&bytes).unwrap(), + r#"{"user_id":"a\"b","hop_index":1,"gen":2}"# + ); } #[test] - fn get_returns_none_for_unknown_id() { - assert!(get("deadbeef00000000").is_none()); + fn expired_when_exp_in_past() { + assert!(is_expired("2000-01-01T00:00:00Z")); } #[test] - fn remove_evicts_session() { - let id = create(fresh_session()); - remove(&id); - assert!(get(&id).is_none()); + fn not_expired_when_exp_far_future() { + assert!(!is_expired("2099-01-01T00:00:00Z")); } } diff --git a/stream-proxy/tests/fixtures/golden-vector.json b/stream-proxy/tests/fixtures/golden-vector.json new file mode 100644 index 00000000..fa79da24 --- /dev/null +++ b/stream-proxy/tests/fixtures/golden-vector.json @@ -0,0 +1,53 @@ +{ + "comment": "Golden vector: Node paseto-ts mints, Rust pasetors must verify/reconstruct + reject tamper/expired/wrong-user. Fixed test key = bytes 1..=32.", + "paserk_local_key": "k4.local.AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA", + "key_bytes": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32 + ], + "expected_user_id": "user-001", + "expected_hop_index": 0, + "expected_gen": 7, + "expected_implicit_assertion": "{\"user_id\":\"user-001\",\"hop_index\":0,\"gen\":7}", + "expected_claims": { + "backend": "jellyfin", + "resource_ref": "item123/src456", + "allowed_hops": "hopkey-abc", + "gen": 7, + "exp": "2099-01-01T00:00:00.000Z" + }, + "valid_token": "v4.local.bizmlhNFkELUqH60tie_2m7iMaPpX5yWGv-1VESB14TtOJETmEgv7_qHKVLMizG4VYUIXx7mqtNraUKmKk-Y88ER0ZCKZNhZD-j9Vh2KxgGAfp5zAVXb1aOWk2k_M2OB6OxBQHXIiw52xxb4E7offsGTafk3S9UNxVBzoZXyCAXRSWQiwrAaC9bHdTJD9cFFLC_yYm99kWuJUnLqv7lOly7b6iod3STep4PkdWj13dI4DVmomMRb79IZ9g.eyJraWQiOiJrMCJ9", + "expired_token": "v4.local.brfm5EoxcgLa9yl8_DLpRr1b3bOjhpUK9oZtNZRcatk9mNA8OlV4UyH5aIsjf3XG1gL8ky7-ez0u9rLu4jtrmHV2TQmypZYLuZ7lluGszy6va-Uzv4WW2ZJxsiMmzFhNZKrG9wyGaBwBq8qR4YCta17SAYPoTVIVLHsAl0MRcw4hRiizSORAYJyTJXOpojuqYLGlU8Z35iy4jGWmO-ypeNYijrp_Bzh7PEVYfCGnmCp_F9NVC-WzwyFbyw.eyJraWQiOiJrMCJ9", + "tampered_token": "v4.local.bizmlANFkELUqH60tie_2m7iMaPpX5yWGv-1VESB14TtOJETmEgv7_qHKVLMizG4VYUIXx7mqtNraUKmKk-Y88ER0ZCKZNhZD-j9Vh2KxgGAfp5zAVXb1aOWk2k_M2OB6OxBQHXIiw52xxb4E7offsGTafk3S9UNxVBzoZXyCAXRSWQiwrAaC9bHdTJD9cFFLC_yYm99kWuJUnLqv7lOly7b6iod3STep4PkdWj13dI4DVmomMRb79IZ9g.eyJraWQiOiJrMCJ9", + "wrong_user_id": "user-EVIL" +} diff --git a/stream-proxy/tests/golden_vector.rs b/stream-proxy/tests/golden_vector.rs new file mode 100644 index 00000000..938b8885 --- /dev/null +++ b/stream-proxy/tests/golden_vector.rs @@ -0,0 +1,153 @@ +//! Cross-language golden-vector test (the critical correctness proof). +//! +//! The fixture `tests/fixtures/golden-vector.json` is minted by the Node side +//! (`scripts/mint-golden-vector.mjs`, using `paseto-ts`) with a FIXED k4.local +//! key. This test loads that fixture and proves the Rust `pasetors` verifier is +//! byte-compatible: +//! +//! - Node-mint → Rust-verify PASS: the valid token verifies and every grant +//! field (backend, resource_ref, allowed_hops, exp, gen) + the implicit +//! assertion (user_id, hop_index, gen) reconstruct exactly. +//! - Tamper REJECT: a byte-flipped token fails (AEAD tag). +//! - Expired REJECT: a past-exp token is rejected by the native exp check. +//! - Wrong-user REJECT: verifying with a different user_id rebuilds a +//! different implicit assertion → tag mismatch → reject. +//! +//! Regenerate the fixture after any mint/serialize change: +//! node scripts/mint-golden-vector.mjs + +use nexus_stream_proxy::session::{self, VerifyError}; +use serde::Deserialize; + +#[derive(Deserialize)] +struct ExpectedClaims { + backend: String, + resource_ref: String, + allowed_hops: String, + #[serde(rename = "gen")] + generation: u64, + exp: String, +} + +#[derive(Deserialize)] +struct Fixture { + paserk_local_key: String, + expected_user_id: String, + expected_hop_index: u64, + expected_gen: u64, + expected_implicit_assertion: String, + expected_claims: ExpectedClaims, + valid_token: String, + expired_token: String, + tampered_token: String, + wrong_user_id: String, +} + +fn load_fixture() -> Fixture { + let raw = include_str!("fixtures/golden-vector.json"); + serde_json::from_str(raw).expect("fixture parses") +} + +#[test] +fn node_mint_rust_verify_reconstructs_grant() { + let f = load_fixture(); + let key = session::parse_local_key(&f.paserk_local_key).expect("PASERK key parses"); + + // First, prove the implicit-assertion serialization matches Node byte-for-byte. + let rust_ia = session::implicit_assertion_bytes(&f.expected_user_id, f.expected_hop_index, f.expected_gen); + assert_eq!( + std::str::from_utf8(&rust_ia).unwrap(), + f.expected_implicit_assertion, + "Rust implicit-assertion bytes must match Node's exactly" + ); + + let grant = session::verify_grant_with_key( + &f.valid_token, + &key, + &f.expected_user_id, + f.expected_hop_index, + f.expected_gen, + ) + .expect("Node-minted token must verify on the Rust side"); + + // Reconstructed grant fields must match the fixture exactly. + assert_eq!(grant.claims.backend, f.expected_claims.backend); + assert_eq!(grant.claims.resource_ref, f.expected_claims.resource_ref); + assert_eq!(grant.claims.allowed_hops, f.expected_claims.allowed_hops); + assert_eq!(grant.claims.exp, f.expected_claims.exp); + assert_eq!(grant.claims.generation, f.expected_claims.generation); + assert_eq!(grant.user_id, f.expected_user_id); + assert_eq!(grant.hop_index, f.expected_hop_index); + assert_eq!(grant.generation, f.expected_gen); + + println!("GOLDEN-VECTOR PASS: Node-mint → Rust-verify reconstructed grant {{ backend: {}, resource_ref: {}, user_id: {}, gen: {} }}", + grant.claims.backend, grant.claims.resource_ref, grant.user_id, grant.generation); +} + +#[test] +fn tampered_token_rejected() { + let f = load_fixture(); + let key = session::parse_local_key(&f.paserk_local_key).unwrap(); + let res = session::verify_grant_with_key( + &f.tampered_token, + &key, + &f.expected_user_id, + f.expected_hop_index, + f.expected_gen, + ); + assert!(res.is_err(), "tampered token must be rejected"); + assert_eq!(res.unwrap_err(), VerifyError::BadToken); + println!("GOLDEN-VECTOR REJECT (tamper): byte-flip → BadToken"); +} + +#[test] +fn expired_token_rejected() { + let f = load_fixture(); + let key = session::parse_local_key(&f.paserk_local_key).unwrap(); + let res = session::verify_grant_with_key( + &f.expired_token, + &key, + &f.expected_user_id, + f.expected_hop_index, + f.expected_gen, + ); + assert!(res.is_err(), "expired token must be rejected"); + assert_eq!(res.unwrap_err(), VerifyError::Expired); + println!("GOLDEN-VECTOR REJECT (expired): past exp → Expired"); +} + +#[test] +fn wrong_user_rejected() { + let f = load_fixture(); + let key = session::parse_local_key(&f.paserk_local_key).unwrap(); + // Same valid token, but verify as a DIFFERENT user → the reconstructed + // implicit assertion differs → the AEAD tag won't match → reject. + let res = session::verify_grant_with_key( + &f.valid_token, + &key, + &f.wrong_user_id, + f.expected_hop_index, + f.expected_gen, + ); + assert!(res.is_err(), "wrong-user verification must be rejected"); + assert_eq!(res.unwrap_err(), VerifyError::BadToken); + println!("GOLDEN-VECTOR REJECT (wrong-user): user '{}' replaying user '{}' token → BadToken (copy-paste defense)", f.wrong_user_id, f.expected_user_id); +} + +#[test] +fn wrong_gen_rejected() { + // A stale gen (logout/disable epoch bump) changes the implicit assertion → + // tag mismatch → reject. Proves gen revocation binds via the assertion. + let f = load_fixture(); + let key = session::parse_local_key(&f.paserk_local_key).unwrap(); + let res = session::verify_grant_with_key( + &f.valid_token, + &key, + &f.expected_user_id, + f.expected_hop_index, + f.expected_gen + 1, + ); + assert!(res.is_err(), "stale gen must be rejected"); + assert_eq!(res.unwrap_err(), VerifyError::BadToken); + println!("GOLDEN-VECTOR REJECT (stale gen): gen+1 → BadToken"); +} diff --git a/stream-proxy/tests/session_integration.rs b/stream-proxy/tests/session_integration.rs index 87f074d3..0c67ee9f 100644 --- a/stream-proxy/tests/session_integration.rs +++ b/stream-proxy/tests/session_integration.rs @@ -74,20 +74,19 @@ async fn hls_session_rewrite_and_proxy_round_trip() { assert!(String::from_utf8_lossy(&body).contains("BANDWIDTH=1280000")); // Rewrite pipeline - let rewritten = nexus_stream_proxy::handlers::hls::rewrite_manifest(&body, "testsess", "testsig", "/stream/", "http://upstream/path/master.m3u8", nexus_stream_proxy::session::AdapterKind::Jellyfin).unwrap(); + let rewritten = nexus_stream_proxy::handlers::hls::rewrite_manifest(&body, "v4.local.TOK", "/stream/", "http://upstream/path/master.m3u8", nexus_stream_proxy::session::AdapterKind::Jellyfin).unwrap(); let s = String::from_utf8_lossy(&rewritten); assert!(!s.contains("secret"), "ApiKey must be stripped"); - assert!(s.contains("/stream/testsess/"), "must rewrite URI to proxy path"); + assert!(s.contains("grant=v4.local.TOK"), "must carry grant on rewritten URIs"); assert!(s.contains("BANDWIDTH=1280000"), "must preserve STREAM-INF"); } #[test] -fn hls_rewrite_embeds_sig_query_param() { - // The reason C1 slipped past the unit tests: they passed a single - // session_id to rewrite_manifest but the router discriminator in main.rs - // requires sig= on every hit. This test asserts the rewritten URIs - // include the sig query so segment requests route back to the session - // handler instead of falling through to the invidious handler. +fn hls_rewrite_carries_grant_on_every_hop() { + // The browser carries the SAME grant back on every child hop — the router in + // main.rs discriminates /stream by the presence of grant=. This test asserts + // the rewritten URIs embed the grant so segment requests route back to the + // session handler instead of falling through to the invidious handler. let input = b"#EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:6 @@ -95,10 +94,10 @@ fn hls_rewrite_embeds_sig_query_param() { /Videos/abc/hls1/main/0.ts?ApiKey=leaky #EXT-X-ENDLIST "; - let out = nexus_stream_proxy::handlers::hls::rewrite_manifest(input, "s1", "mysig123", "/stream/", "http://upstream/path/master.m3u8", nexus_stream_proxy::session::AdapterKind::Jellyfin) + let out = nexus_stream_proxy::handlers::hls::rewrite_manifest(input, "v4.local.GRANT", "/stream/", "http://upstream/path/master.m3u8", nexus_stream_proxy::session::AdapterKind::Jellyfin) .expect("parses and rewrites"); let s = std::str::from_utf8(&out).unwrap(); - assert!(s.contains("/stream/s1/"), "rewrites URI to proxy path"); - assert!(s.contains("?sig=mysig123"), "embeds sig= for router discrimination"); + assert!(s.contains("stream?grant=v4.local.GRANT"), "rewrites URI to grant path"); + assert!(s.contains("&suffix="), "hex-encodes upstream as suffix"); assert!(!s.contains("leaky"), "strips ApiKey"); } diff --git a/svelte.config.js b/svelte.config.js index 9bfc792c..5e78f02d 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -15,7 +15,17 @@ const config = { mode: 'auto', directives: { 'default-src': ['self'], - 'script-src': ['self', 'unsafe-inline', 'blob:', 'https://static.cloudflareinsights.com'], + // 'wasm-unsafe-eval' lets the browser compile/instantiate WebAssembly + // (the nucleo search matcher) WITHOUT allowing general 'unsafe-eval' — + // the narrow, modern directive. Without it the wasm CompileError-s and + // search silently falls back to unranked order. + 'script-src': [ + 'self', + 'unsafe-inline', + 'wasm-unsafe-eval', + 'blob:', + 'https://static.cloudflareinsights.com' + ], 'style-src': ['self', 'unsafe-inline'], 'img-src': ['self', 'data:', 'blob:', 'http:', 'https:'], 'font-src': ['self', 'data:'], diff --git a/vite.config.ts b/vite.config.ts index 98ea77d5..02f9707c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,11 +1,20 @@ import { sveltekit } from '@sveltejs/kit/vite'; import tailwindcss from '@tailwindcss/vite'; +import wasm from 'vite-plugin-wasm'; import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [tailwindcss(), sveltekit()], + // `wasm` lets us import nucleo-matcher-wasm (a wasm-pack "bundler target" that + // does `import * as wasm from './…wasm'`). Used CLIENT-side only — the home + // page lazy-imports it in the browser to rank search results with the real + // fzf-grade matcher, so SSR never touches the wasm. + plugins: [tailwindcss(), wasm(), sveltekit()], + // nucleo's wasm glue initialises via top-level await; raise the target so it + // survives the build instead of pulling in vite-plugin-top-level-await (which + // drags in a native @swc/core). All target browsers support TLA. + build: { target: 'esnext' }, optimizeDeps: { - exclude: ['pdfjs-dist'] + exclude: ['pdfjs-dist', 'nucleo-matcher-wasm'] } // Note: we do NOT set manualChunks for pdfjs. The PdfReader uses // `pdfjs-dist` from node_modules, while foliate-js's EPUB engine