commit 737bf19fd16c2b1d76e9ab58870ad1f00513182c Author: kami Date: Tue Jul 14 01:35:52 2026 +0400 initial state: muzick music player + recommendation engine diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..39d33b5 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(rtk npm *)", + "Bash(npm --prefix /mnt/server/home/kami/apps/muzick/workers run typecheck)", + "Bash(echo \"EXIT=$?\")", + "Bash(rtk ls *)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..84e6739 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Last.fm API credentials (required for artist images, tags) +LASTFM_API_KEY="your-lastfm-api-key" +LASTFM_SHARED_SECRET="your-lastfm-shared-secret" + +# MusicBrainz contact header +MUSICBRAINZ_CONTACT="your-app-name/1.0 (https://github.com/your/repo)" + +# Discogs token (optional, for release metadata) +DISCOGS_TOKEN="your-discogs-token" + +# SOCKS5 proxy URL (optional, for geo-bypass) +SOCKS_PROXY_URL=socks5://127.0.0.1:10808 diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml new file mode 100644 index 0000000..f1de51e --- /dev/null +++ b/.github/workflows/typecheck.yml @@ -0,0 +1,23 @@ +name: Typecheck + +on: + push: + pull_request: + +jobs: + typecheck: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + package: [backend, workers] + defaults: + run: + working-directory: ${{ matrix.package }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - run: npm run typecheck diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53571ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Node.js +node_modules/ +npm-debug.log +yarn-error.log +.pnpm-debug.log + +# Docker +*.log +docker-compose.override.yml + +# Environment +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Build output +dist/ +build/ + +# Data persistence +data/postgres/ +data/redis/ +data/typesense/ + +# OS +.DS_Store +Thumbs.db diff --git a/03-music-core-backend.md b/03-music-core-backend.md new file mode 100644 index 0000000..d05ec2a --- /dev/null +++ b/03-music-core-backend.md @@ -0,0 +1,163 @@ +# music ("resonance") — core backend spec + +FastAPI + sqlite. The streaming/library core. Recommendation/vibe engine is a separate spec (04). Music lives at `/mnt/hdd1/media/Music`. Dark/blue frontend is spec 05. + +> Working name: **resonance**. Rename freely. + +## Principles +- **Tag-indexed once into sqlite**, queried from there. Tag parsing is heavier than `stat()`, so it only runs on indexing, not per request. +- Incremental re-index: skip files whose mtime is unchanged since last index. +- Metadata: prefer embedded tags (ID3 for mp3, Vorbis for flac/ogg, MP4 atoms for m4a). Fall back to folder/filename parsing when tags are missing/garbage. +- Cover art: embedded first; fall back to `cover.jpg|folder.jpg|front.jpg` in the track's directory. + +## Libraries +- **mutagen** — tag reading (mp3/flac/ogg/m4a/wav). Mature, pure-python. +- Optional: **Pillow** to normalize/resize embedded cover art into a cache. + +## sqlite schema (music.db) + +```sql +CREATE TABLE IF NOT EXISTS tracks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT UNIQUE NOT NULL, + title TEXT, + artist TEXT, + album_artist TEXT, + album TEXT, + track_no INTEGER, + disc_no INTEGER, + year INTEGER, + genre TEXT, + duration REAL, -- seconds + bitrate INTEGER, + sample_rate INTEGER, + channels INTEGER, + codec TEXT, -- mp3/flac/... + feats TEXT, -- parsed featured artists, JSON array + mtime REAL NOT NULL, -- file mtime at index time + size INTEGER, + has_embedded_cover INTEGER DEFAULT 0, + cover_path TEXT, -- resolved external cover, if any + mbid TEXT, -- musicbrainz recording id (spec 04) + indexed_at TEXT NOT NULL, + -- library state + probation INTEGER DEFAULT 0, -- 1 = recommended candidate not yet promoted (spec 04) + source TEXT DEFAULT 'library', -- 'library' | 'recommendation' + rec_source TEXT, -- 'lastfm' | 'musicbrainz' (spec 04) + added_at TEXT +); + +CREATE TABLE IF NOT EXISTS albums ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + album_artist TEXT, + year INTEGER, + cover_path TEXT, + mbid TEXT, + UNIQUE(name, album_artist) +); + +CREATE TABLE IF NOT EXISTS artists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + mbid TEXT, + image_path TEXT, + genres TEXT -- JSON array (spec 04 fills via lastfm/mb) +); + +CREATE TABLE IF NOT EXISTS favorites ( + track_id INTEGER PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + track_id INTEGER REFERENCES tracks(id) ON DELETE CASCADE, + played_at TEXT NOT NULL, + completed INTEGER DEFAULT 0 -- 1 if played to ~end (counts toward play_count) +); + +CREATE TABLE IF NOT EXISTS play_counts ( + track_id INTEGER PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + count INTEGER DEFAULT 0, + last_played TEXT +); + +CREATE TABLE IF NOT EXISTS prefs ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +``` + +Indexes: `tracks(artist)`, `tracks(album)`, `tracks(album_artist)`, `tracks(genre)`, `tracks(probation)`, `history(played_at)`. + +## Indexing +- `index_library()`: + - rglob music root for audio extensions. + - For each file: if `path` in tracks and `mtime` unchanged → skip. Else parse tags via mutagen, upsert track row; upsert album + artist rows. + - Cover resolution: if embedded art present, extract once to a cover cache dir on hdd2 (`/mnt/hdd2/resonance/covers/.jpg`), store `cover_path`. Else look for sidecar cover files in dir. + - After full pass: delete track rows whose `path` no longer exists (and weren't promoted-from-recommendation pending — they still get removed if file's gone). +- Trigger: on startup (background thread), and a manual `POST /api/library/reindex`. +- Feats parsing: from title/artist via patterns — `feat.`, `ft.`, `featuring`, `(with …)`, `, ` in artist field — store normalized JSON in `feats`. + +## Endpoints — library + +``` +GET /api/artists?sort=name|count -> artists w/ track + album counts +GET /api/artists/{id} -> artist + albums + tracks +GET /api/albums?sort=name|year|artist -> albums w/ track counts, cover +GET /api/albums/{id} -> album + ordered tracks +GET /api/tracks?sort=title|artist|added|plays&limit=&offset= -> paginated +GET /api/track/{id} -> full track meta +GET /api/search?q= -> fuzzy across tracks/artists/albums +GET /api/cover/{track_id|album_id} -> image (from cover cache); 404->placeholder +``` + +### Fuzzy search +- Normalize (lowercase, strip diacritics for matching but keep originals for display — important for Cyrillic/mixed library). +- Match across title, artist, album, album_artist. Rank: exact > prefix > substring > token subsequence. Cap results per category. SQLite + python ranking is fine at this library size; consider `fts5` virtual table if it grows. + +## Endpoints — playback / streaming + +``` +GET /api/stream/{track_id} -> audio stream, MUST support HTTP Range +GET /api/lyrics/{track_id} -> synced lyrics (spec 04 provider chain) +``` + +- `/api/stream` uses range requests so the player can seek. FileResponse handles this, but verify `Accept-Ranges` for the player's needs; if transcoding is added later, gate behind a `?transcode=` param. v1 = direct file passthrough (no transcode). + +### Playback config (prefs) +`prefs` holds playback mode and options: +- `playback_mode`: `basic` | `gapless` | `crossfade` | `interstitial` +- `crossfade_ms`: int (when crossfade) +- `interstitial_track_id`: track id to insert between songs (the "Thomas the Tank Engine between every song" mode). Cute, keep it. +Gapless/crossfade are primarily **client-side** (Web Audio) concerns; backend just streams. Backend stores the prefs and serves the interstitial track like any other. + +## Endpoints — favorites / history / counts + +``` +POST /api/favorite/{track_id} -> toggle favorite +GET /api/favorites -> favorited tracks +POST /api/history body {track_id, completed} -> log play; if completed, bump play_counts +GET /api/history?limit= -> recent plays +GET /api/stats/top?by=plays&limit= -> most played +GET /api/prefs / PUT /api/prefs -> player + app prefs (same pattern as kdrive) +``` + +### Play accounting +- Client logs a play to `/api/history` with `completed=true` when playback passes a threshold (e.g. ≥50% or last 10s reached). That increments `play_counts`. Scrubbed-away early plays log with `completed=false` (history but no count). + +## Global shuffle +``` +GET /api/shuffle/all?limit=500&exclude_probation=false +``` +Returns a shuffled list of track ids spanning the whole library (optionally include probation candidates mixed in — see spec 04). Frontend loads this as the queue. The "one button, everything shuffled" requirement. + +## Deletion (ties into spec 04 dislike flow) +``` +DELETE /api/track/{track_id} -> remove file from fs + all db rows (favorites/history/counts cascade) +``` +Used by the dislike lifecycle's final step. Hard delete, irreversible. Path-safety: only delete within the music root. + +## Threading +Indexing + cover extraction run in a background thread on startup and on `reindex`. SQLite connections per-thread. diff --git a/04-music-recommendation-backend.md b/04-music-recommendation-backend.md new file mode 100644 index 0000000..3b6db6e --- /dev/null +++ b/04-music-recommendation-backend.md @@ -0,0 +1,129 @@ +# music — recommendation / vibe engine spec + +Layers on top of core (spec 03). Handles enrichment (MusicBrainz + Last.fm), the vibe-endless-queue, candidate lifecycle, and the dislike → delayed-delete flow. Acquisition is a pluggable hook — the engine never fetches copyrighted audio itself. + +## External providers +- **MusicBrainz** (no key, rate-limited 1 req/s, set a proper User-Agent): canonical recording/artist/release MBIDs, genres/tags. Used during enrichment to stamp `mbid` and genre data. +- **Last.fm** (free API key required): `track.getSimilar`, `artist.getSimilar`, `tag.getTopTracks`. Drives recommendations + vibe-queue. +- Store the **recommendation source** per candidate (`rec_source` on tracks: `lastfm` | `musicbrainz`) so feedback can be attributed. +- Respect rate limits: queue external calls, cache responses in sqlite. + +```sql +CREATE TABLE IF NOT EXISTS mb_cache ( + key TEXT PRIMARY KEY, -- e.g. "recording::" + payload TEXT NOT NULL, -- JSON + fetched_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS lastfm_cache ( + key TEXT PRIMARY KEY, -- e.g. "similar:<mbid|artist-title>" + payload TEXT NOT NULL, + fetched_at TEXT NOT NULL +); + +-- candidate dislike lifecycle +CREATE TABLE IF NOT EXISTS dislikes ( + track_id INTEGER PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + disliked_at TEXT NOT NULL, + warn_after TEXT NOT NULL, -- disliked_at + grace period ("a couple days") + warned_at TEXT, -- set when reminder fired + delete_after TEXT, -- warned_at + 24h + state TEXT NOT NULL -- 'hidden' | 'warned' | 'deleted' +); + +CREATE TABLE IF NOT EXISTS feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + track_id INTEGER, + rec_source TEXT, -- which provider suggested it + action TEXT NOT NULL, -- 'promoted' | 'disliked' | 'skipped' + at TEXT NOT NULL +); +``` + +## Enrichment +- After core indexing, a background enrichment pass fills missing `mbid`, `genre`, artist `genres`, artist `image_path`: + - MusicBrainz lookup by artist+title → recording MBID + tags. + - Last.fm `artist.getInfo` / `track.getInfo` for tags + similar seeds; cache. +- Throttled, cached, resumable. Never blocks playback. + +## Recommendation / vibe queue +``` +GET /api/vibe?seed_track_id=&limit= -> ordered vibe queue (track ids) +GET /api/vibe/from-genre?genre=&limit= +``` +Algorithm: +1. Seed = current track / chosen track / genre. +2. Pull similar from Last.fm (`track.getSimilar`, `artist.getSimilar`, `tag.getTopTracks`), cached. +3. Score each candidate against the **owned library**: + - +score if artist/album already owned, genre overlap, similar to favorites, low recent-play (freshness), not disliked. +4. **Mix**: vibe queue interleaves owned tracks with promoted-from-recommendation tracks — recommendations are NOT front-loaded; they're shuffled into the stream so it feels organic. +5. Candidates **not yet owned** → enter the candidate pipeline (below) rather than playing immediately. + +## Candidate lifecycle (acquisition) +- Hard cap: **max 5 candidates in probation at once** (`tracks.probation=1`). Engine won't request new acquisitions beyond the cap. +- When the engine wants to surface an unowned suggestion, it calls the acquisition hook: + +```kotlin +// or python equivalent — defined seam, implemented by the operator +interface AcquisitionProvider { + /** Fetch audio for a candidate. Return the local file path on success, null to skip. + * The engine does NOT care where the bytes come from. */ + suspend fun acquire(candidate: TrackCandidate): AcquiredFile? +} + +data class TrackCandidate( + val title: String, + val artist: String, + val album: String?, + val mbid: String?, + val recSource: String, // lastfm | musicbrainz +) +data class AcquiredFile(val path: String) +``` + - Default shipped impl: `NoopAcquisitionProvider` (returns null → candidate stays metadata-only, never plays). Operator wires a real one (bandcamp purchase dl, FMA/Jamendo CC, internet archive, personal rips, etc.). + - On successful acquire: file lands in music root, indexed as a track with `probation=1`, `source='recommendation'`, `rec_source` set, hidden from normal library lists (filtered by `probation=0`) but eligible to appear mixed into the **vibe queue**. + +### Promotion +- A probation track that gets played and **not disliked** (passes the play-completion threshold) → `probation=0`, `source` stays `recommendation` for analytics but it's now full library, eligible as a future recommendation seed. Log `feedback(action='promoted')`. + +## Dislike → delayed delete flow +Exact lifecycle: +1. **User dislikes a track** → insert/[update] `dislikes` row: `state='hidden'`, `disliked_at=now`, `warn_after=now + GRACE` (GRACE = "a couple days", e.g. 48h). Track is **hidden** from library + queues immediately. **File stays on disk.** Also `feedback(action='disliked')`. +2. **Reminder**: a periodic sweep finds `state='hidden'` rows past `warn_after` → fire notification (ntfy) / surface a toast in UI: *"You disliked '<track>'. It will be deleted in 24h — are you sure?"* Set `warned_at=now`, `delete_after=now + 24h`, `state='warned'`. +3. **Final**: sweep finds `state='warned'` rows past `delete_after` where the dislike still stands → **delete file from fs + all db rows** (calls core `DELETE /api/track`). Set `state='deleted'` (or just remove the dislike row since the track row is gone). +4. **Un-dislike** at any point before final deletion → remove `dislikes` row, un-hide the track. It's spared. + +``` +POST /api/dislike/{track_id} -> start lifecycle (hide + schedule) +DELETE /api/dislike/{track_id} -> un-dislike (spare it), un-hide +GET /api/dislikes -> pending dislikes + their states/timers +POST /api/dislikes/sweep -> manual trigger of the sweep (also runs on a timer) +``` + +### Sweep scheduler +- A background timer (e.g. hourly) runs the sweep: hidden→warned (fires ntfy), warned→deleted. Hourly granularity is fine for day-scale timers. +- ntfy integration: POST to the ntfy topic (reuse the server's ntfy from the parked list) for the reminder. + +## Feedback loop (future-facing, stub now) +- `feedback` table records promoted/disliked/skipped with `rec_source`. +- Later: weight providers/genres by acceptance rate (promoted vs disliked) to bias future recommendations. v1 just records; scoring tweak is a later iteration. + +## Endpoints summary (this layer) +``` +GET /api/vibe +GET /api/vibe/from-genre +POST /api/dislike/{id} +DELETE /api/dislike/{id} +GET /api/dislikes +POST /api/dislikes/sweep +POST /api/enrich -> manual enrichment pass +GET /api/recommendations -> current probation candidates + why (rec_source, seed) +``` + +## Config / prefs +- `lastfm_api_key` (prefs or env) +- `mb_user_agent` (required by MusicBrainz) +- `dislike_grace_hours` (default 48) +- `dislike_final_hours` (default 24) +- `max_candidates` (default 5) +- `ntfy_topic_url` for reminders diff --git a/05-music-frontend.md b/05-music-frontend.md new file mode 100644 index 0000000..a3cfb99 --- /dev/null +++ b/05-music-frontend.md @@ -0,0 +1,80 @@ +# music — frontend spec + +React (vite). Dark theme by default, blue accent. Web UI is v1; backend is API-first so a mobile app can reuse the same endpoints later. + +## Theme +- **Dark by default.** Blue accent (`--accent: #2D7FF9` or similar; pick one and define tints like kdrive's accent system). +- Reuse the design-token approach from kdrive (CSS vars, Geist font). This is a distinct app with its own palette but shared visual language. +- Animations: tasteful — now-playing bar slide-up, queue/lyrics panel slide-in, album art crossfade on track change, subtle hover/press states, progress bar smoothing, shuffle/loop button state transitions. + +## Layout +- **Left sidebar**: nav (Artists / Albums / Songs / Favorites / Recently played / Vibe), profile/avatar (opens settings modal). +- **Main**: list/grid views per section. +- **Bottom now-playing bar** (persistent): cover thumb, title, artist + feats, prev / play-pause / next, volume slider, shuffle toggle, loop toggle (off → all → one), progress/seek bar, lyrics button, queue button. + +## Views +### Artists +- List/grid of artists (image if enriched, else monogram). Click → artist page: header (image, name, genres), albums, all tracks. + +### Albums +- Grid of album covers (sort: name / year / artist). Click → album page: cover, title, artist, year, ordered tracklist, play / shuffle album. + +### Songs +- Virtualized list (library can be large): title, artist, album, duration, play count, favorite toggle. Sort by title/artist/added/plays. Fuzzy search box. + +### Favorites / Recently played +- Favorites: from `/api/favorites`. Recently played: from `/api/history`. + +### Vibe +- "Start a vibe" from current track / an artist / a genre. Calls `/api/vibe`. Shows the mixed queue; recommendation candidates are visually tagged subtly (small dot/"suggested") but interleaved, not grouped. + +## Player controls (now-playing bar) +- **Global shuffle button** (prominent, maybe in topbar or sidebar too): one tap → `GET /api/shuffle/all` → loads entire library shuffled as queue, starts playing. The headline feature. +- **Prev / Play-Pause / Next**. +- **Loop toggle**: cycles off → loop-all (current queue/album) → loop-one (single track). Distinct icons per state. +- **Volume slider**. +- **Seek bar**: draggable, shows elapsed/total. +- **Cover + title + artist + feats**: feats rendered subtly after artist (e.g. "Artist · feat. X, Y"). +- **Lyrics button**: opens lyrics panel. +- **Queue button**: opens queue panel. + +## Playback engine (client) +- Web Audio / `<audio>` with `/api/stream/{id}` (range-enabled). +- Playback modes from prefs: + - **basic**: sequential. + - **gapless**: preload next track, start without silence (dual audio elements or Web Audio buffering). + - **crossfade**: fade out current / fade in next over `crossfade_ms`. + - **interstitial**: play the configured interstitial track between every song (the gag mode). +- Log plays to `POST /api/history` with `completed` once threshold reached (≥50% or final 10s). + +## Lyrics panel +- Slide-in panel. Calls `/api/lyrics/{track_id}`. +- **Synced** (LRC): auto-scroll, highlight active line, tap a line to seek. +- Falls back to plain text or "no lyrics found". +- Provider chain handled server-side (Musixmatch → LRCLIB), frontend just renders. + +## Queue panel +- Slide-in. Shows **prev tracks** (history within session) and **next tracks** (upcoming). +- Reorder (drag), remove, jump-to. +- Queue resets on session end (no persistence — per the decision). + +## Favorites / dislike +- Heart toggle on tracks/now-playing → `POST /api/favorite/{id}`. +- **Dislike** control (e.g. thumbs-down in now-playing context menu) → `POST /api/dislike/{id}`. Track hides immediately. A toast confirms. +- When a dislike reminder fires (server sweep), the app surfaces a toast: *"You disliked '<track>' — deleting in 24h. Undo?"* with an Undo action calling `DELETE /api/dislike/{id}`. (Also delivered via ntfy out-of-app.) + +## Settings modal (avatar click) +- **Appearance**: theme (dark default, allow light), blue accent + maybe a couple alt accents. +- **Playback**: mode (basic/gapless/crossfade/interstitial), crossfade ms (when crossfade), interstitial track picker (when interstitial). +- **Recommendations**: enable/disable vibe acquisition, max candidates (read-only display of the cap), Last.fm key field, MusicBrainz UA. +- **Library**: reindex button (`POST /api/library/reindex`), enrich button (`POST /api/enrich`). +- Persist via `/api/prefs`. + +## Search +- Global fuzzy search (topbar) → `/api/search`, grouped results: Artists / Albums / Songs. Handles Cyrillic/mixed scripts (display original, match normalized). + +## API-first note +- All state lives behind the documented endpoints so a future mobile client reuses them. No frontend-only business logic that the API can't reproduce. Keep auth simple for v1 (WG-gated, like the rest); leave room for token auth later for mobile over the tunnel. + +## Deploy +- Same pattern as kdrive: vite build → served by the FastAPI app (or its own static mount). nginx vhost via the panel: `music.kvmx.ru` (replaces the swingmusic/navidrome entry) → `http://localhost:<port>`. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..023a1e2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# Muzick — Agent Context + +music player + recommendation engine. `muzick.kvmx.ru:5174`. + +## ports +- `5174:80` — frontend (docker) +- `3000:3000` — backend api (docker, internal) + +## docker-compose services +- `db` — postgres 16, schema at `backend/src/db/schema.sql` +- `redis` — redis 7, job queue +- `search` — typesense 0.25.1 (pinned), full-text search +- `backend` — fastify/typescript, music dir mount at `/music` +- `frontend` — vite SPA, served by nginx inside container +- `worker` — metadata enrichment (lastfm, discogs, musicbrainz), `network_mode: host` + +## env (.env) +- `LASTFM_API_KEY`, `LASTFM_SHARED_SECRET` +- `MUSICBRAINZ_CONTACT` +- `DISCOGS_TOKEN` +- `SOCKS_PROXY_URL=socks5://192.168.1.104:10808` — for external api calls + +## gotchas +- worker uses `network_mode: host` + SOCKS5 proxy for metadata lookups. +- typesense is pinned to 0.25.1 — not latest. don't bump casually, the api changes between major versions. +- db schema is in `backend/src/db/schema.sql` — dropped in via docker-entrypoint-initdb.d. +- music dir is read-only bind from `/mnt/hdd1/media/Music`. +- there's also an older `muzick.service` systemd unit that runs the backend solo on port 5213 — that's the pre-docker version and may conflict. the docker compose is the active deployment. diff --git a/README.md b/README.md new file mode 100644 index 0000000..26afe18 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# muzick + +A high-performance, distributed music orchestration and recommendation platform. + +## Overview + +**muzick** is designed to manage a local music library while providing an "infinite vibe" listening experience. It bridges the gap between a local filesystem and advanced discovery engines through a tiered recommendation architecture. + +## Tech Stack + +### **Frontend** +- **Framework:** React +- **Routing:** TanStack Router +- **Data Fetching:** TanStack Query (with Look-ahead Buffering) +- **State Management:** Zustand (for Session/Vibe state) +- **Styling:** CSS Variables (Customizable Themes) + +### **Backend** +- **Runtime:** Node.js / TypeScript +- **Framework:** Fastify +- **Task Queue:** BullMQ (via Redis) +- **Search:** Typesense + +### **Infrastructure & Data** +- **Database:** PostgreSQL (Source of truth for metadata, relationships, and session state) +- **Cache/Queue:** Redis +- **Audio Analysis:** Essentia (via Worker processes) +- **External Metadata:** MusicBrainz, Discogs, LRCLib, Cover Art Archive + +## Core Concepts + +- **The Rolling Vibe:** A continuous, evolving stream of music that uses a "Rolling Window" of tracks. It interleaves owned library tracks with high-probability "probation" tracks (external discoveries). +- **The Dislike Lifecycle:** A multi-stage state machine that protects users from accidental deletions while ensuring the library stays clean. +- **Tiered Similarity:** Instant metadata-based matches, followed by deep audio-feature similarity. + +## Getting Started + +### Prerequisites +- Docker & Docker Compose + +### Running Locally +```bash +docker-compose up -d +``` diff --git a/SESSION-07-07-2026.md b/SESSION-07-07-2026.md new file mode 100644 index 0000000..5855eb6 --- /dev/null +++ b/SESSION-07-07-2026.md @@ -0,0 +1,96 @@ +# Session — 07 July 2026 + +## Scaffolded: v2 Recommendation Engine — code complete, not yet deployed (Systems A–E + Phase 4) + +All six axioms encoded as running code. Every claim is evidence, not fact. +MusicBrainz is the structural spine, not truth. Conflicts coexist in the graph. + +### Files created (8 new) + +| File | Lines | System | +|---|---|---| +| `backend/src/services/generators.service.ts` | 446 | **C** — 8 candidate generators | +| `backend/src/services/session-director.service.ts` | 761 | **D** — Session planner + fatigue + arcs | +| `backend/src/services/discovery.service.ts` | 297 | **E** — Graph walks + probation lifecycle | +| `backend/src/services/image-enrichment.service.ts` | 105 | **Phase 4** — Image candidate pipeline | +| `workers/src/mb-spine-writer.ts` | 136 | **A** — MB artist-credit → claims writer (wired into enrichment.service.ts) | +| `backend/src/routes/graph.routes.ts` | 152 | Graph API (claims, fusion, sources, evidence) | +| `backend/src/routes/v2.routes.ts` | 130 | v2 vibe endpoints (start, next, feedback, state) | +| `backend/src/routes/discovery.routes.ts` | 103 | Discovery + image API endpoints | + +### Files modified (3) + +| File | Changes | +|---|---| +| `backend/src/db/schema.sql` | +187 lines: 9 new tables + 2 ALTER TABLE + indexes | +| `backend/src/services/db.service.ts` | +700 lines: 6 migrations, 12 new methods, 6 interfaces, evidence wiring in recordPlay/recordSkip/recordFeedback/dislikeTrack, listener-behavior writer in recordPlay | +| `backend/src/app.ts` | +4 lines: imports + registrations for v2 + discovery routes | + +### System-by-system + +**System A — Knowledge Graph (probabilistic fusion)** +- `source_trust` table: configurable trust weights (mb=0.90, tag=0.30, listener_behavior=0.40) +- `claims` table: graph spine, unique on (subject, pred, object, source, user_id) +- `claim_fusion` view: weighted vote SUM(trust × confidence × recency) +- `track_artists_v2` / `album_artists_v2`: compatibility views over fusion +- `recording_mbid` on tracks: structural spine anchor +- Methods: upsertClaim, upsertClaims, getClaimsBySubject, getFusedValue, getFusedTrackArtists +- Backfill migration `20260707_backfill_claims`: existing track_artists → claims (tag), artist_similar → same_scene_as (lastfm) + +**System B — Listener Model** +- `evidence` table: append-only signal stream +- `listener_beliefs` table: per-profile beliefs with decay +- Every play/skip/feedback writes evidence rows automatically +- Methods: recordEvidence, recordEvidenceBatch, getListenerBeliefs, updateListenerBelief + +**System C — Candidate Generators (8 generators)** +- `comfortGenerator`: longterm affinity > 0.5 artists +- `adjacentGenerator`: 2-hop graph walks from seed artist +- `discoveryGenerator`: unfamiliar artists via same_scene_as from trusted artists, gated by novelty_tolerance +- `deepDiveGenerator`: obsession album deep cuts in album order +- `revivalGenerator`: stale high-affinity artists (>90d untouched) +- `experimentalGenerator`: random unfamiliar genres +- `contextualGenerator`: context-tagged preferences +- `noveltyGenerator`: recent releases (≤60d) via same_scene_as/same_label_as/produced edges from trusted artists +- All candidates carry non-empty `ClaimEdge[]` explanations (graph paths) + +**System D — Session Director (runs alongside v1; getNextVibeChunk not yet deleted)** +- `buildState`: energy from last 5 plays, novelty_hunger from discovery profile, session age +- `computeFatigue`: exponential decay per dimension (track/7d-30d, artist/24h-8h, genre/24h-8h, language/2h-1h) +- `getBudgets`: reads diversity_budgets, calculates spend from recent history +- `pickArc`/`getArcSlots`: energy+novelty-based arc templates (comfort/discovery/energetic/late-night) +- `rankCandidates`: multi-objective weighted sum (enjoyment, fatigue, diversity, entropy, repetition) +- `detectAntiLoop`: Herfindahl-Hirschman Index + fatigue threshold +- `buildPlan`/`replan`: full orchestration loop with slot filling +- 27KB of planner logic + +**System E — Acquisition Pipeline** +- `walkGraphForDiscovery`: walks same_scene_as/featured_on edges to artists not in library +- `evalCandidates`: fused relevance + novelty tolerance + diversity check → acquire/retire +- `evalProbation`/`sweepProbation`: evidence-based retain/retire lifecycle +- `runMetaLearning`: discovery source retention analysis +- `discovery_candidates` + `probation_status` columns + +**Phase 4 — Image Candidates** +- `fetchImagesForArtist`/`fetchImagesForAlbum`: write candidate rows per source +- `selectBestImage`: source-priority-tiered selection, updates image_path/artwork_id +- `image_candidates` table with source/verified tracking + +### Evidence wiring (every interaction) +- recordPlay → playback_completed (longterm +0.10) + replay_within_24h if applicable + alias_of/same_scene_as behavior claims +- recordSkip → skip_quick (negative -0.20) +- recordFeedback(promoted) → add_to_favorites (longterm +0.60) +- recordFeedback(disliked) → hidden (negative -0.60) +- dislikeTrack → hidden (negative -0.60) + +### Verification +- `npx tsc --noEmit` — 0 errors +- `npx vitest run` — 30/30 pass (mocked shape checks, not DB-state) +- No git repo — changes uncommitted +- NOT deployed: live backend container is pre-v2; `/api/v2/*` and `/api/graph/*` return 404; DB has zero v2 tables. See `docs/architecture/v2-fix-plan.md` for the fix + deploy plan. + +### Next +- Execute `docs/architecture/v2-fix-plan.md` (MV refresh, decay job, bug fixes, deploy) +- After deploy + verify: wire the v2 endpoint into the frontend Vibe page (replace v1 vibeService calls) +- Build yt-dlp worker for System E acquisition (download candidates) +- After v2 is verified in production: delete v1 CTE (`getNextVibeChunk`), `vibe.routes.ts`, `feedback` table, `artist_similar` table per the doc's "Retiring v1" list diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..3f29ae6 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.git +.env diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..208d423 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,8 @@ +FROM node:20-slim +WORKDIR /app +COPY package*.json ./ +RUN npm install --legacy-peer-deps +COPY . . +RUN npm run build +EXPOSE 3000 +CMD ["npm", "run", "start"] diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..816a975 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,3206 @@ +{ + "name": "muzick-backend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "muzick-backend", + "version": "0.1.0", + "dependencies": { + "bullmq": "^5.1.0", + "fastify": "^4.24.3", + "pg": "^8.11.3", + "redis": "^5.0.0", + "typesense": "^3.0.6" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@types/pg": "^8.20.0", + "tsx": "^4.6.2", + "typescript": "^5.3.3", + "vitest": "^4.1.10" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", + "integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.11.0", + "ajv-formats": "^2.1.1", + "fast-uri": "^2.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", + "integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==", + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz", + "integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==", + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^5.7.0" + } + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz", + "integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@redis/bloom": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz", + "integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/client": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", + "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz", + "integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/search": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz", + "integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/time-series": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz", + "integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.42", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz", + "integrity": "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz", + "integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==", + "license": "MIT", + "dependencies": { + "@fastify/error": "^3.3.0", + "fastq": "^1.17.1" + } + }, + "node_modules/axios": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/bullmq": { + "version": "5.78.0", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.78.0.tgz", + "integrity": "sha512-tT9jJmbobk9ueEfFc22egLmgwCcMGgOjZ5Y1cvgczBPv1JUmC7iHQVbQtqku2YBE5dE9uzdVpxIrBvL/YAjGwA==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.10.1", + "msgpackr": "2.0.2", + "node-abort-controller": "3.1.1", + "semver": "7.8.0", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "redis": ">=5.0.0" + }, + "peerDependenciesMeta": { + "redis": { + "optional": true + } + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-content-type-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", + "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==", + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz", + "integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==", + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.1.0", + "ajv": "^8.10.0", + "ajv-formats": "^3.0.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^2.1.0", + "json-schema-ref-resolver": "^1.0.1", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz", + "integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==", + "license": "MIT" + }, + "node_modules/fastify": { + "version": "4.29.1", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz", + "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^3.5.0", + "@fastify/error": "^3.4.0", + "@fastify/fast-json-stringify-compiler": "^4.3.0", + "abstract-logging": "^2.0.1", + "avvio": "^8.3.0", + "fast-content-type-parse": "^1.1.0", + "fast-json-stringify": "^5.8.0", + "find-my-way": "^8.0.0", + "light-my-request": "^5.11.0", + "pino": "^9.0.0", + "process-warning": "^3.0.0", + "proxy-addr": "^2.0.7", + "rfdc": "^1.3.0", + "secure-json-parse": "^2.7.0", + "semver": "^7.5.4", + "toad-cache": "^3.3.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-my-way": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz", + "integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^3.1.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", + "integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", + "integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==", + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^0.7.0", + "process-warning": "^3.0.0", + "set-cookie-parser": "^2.4.1" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.2.tgz", + "integrity": "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pino/node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/redis": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", + "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "5.12.1", + "@redis/client": "5.12.1", + "@redis/json": "5.12.1", + "@redis/search": "5.12.1", + "@redis/time-series": "5.12.1" + }, + "engines": { + "node": ">= 18.19.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz", + "integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/safe-regex2": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", + "integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==", + "license": "MIT", + "dependencies": { + "ret": "~0.4.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toad-cache": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.1.tgz", + "integrity": "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typesense": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/typesense/-/typesense-3.0.6.tgz", + "integrity": "sha512-d3LL1qOLS8FCRxgAOqH+uDuK+VVA+/HYI1frU9fjYVwOg68mg/dX6XTtZ4yKKAkDCasB/se5uh/ZpMdOz/uJNg==", + "license": "Apache-2.0", + "dependencies": { + "axios": "^1.15.0", + "loglevel": "^1.9.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..706f10c --- /dev/null +++ b/backend/package.json @@ -0,0 +1,29 @@ +{ + "name": "muzick-backend", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "start": "node dist/server.js", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "prebuild": "tsc --noEmit", + "build": "tsc", + "setup-db": "./scripts/setup-db.sh" + }, + "dependencies": { + "bullmq": "^5.1.0", + "fastify": "^4.24.3", + "pg": "^8.11.3", + "redis": "^5.0.0", + "typesense": "^3.0.6" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@types/pg": "^8.20.0", + "tsx": "^4.6.2", + "typescript": "^5.3.3", + "vitest": "^4.1.10" + } +} diff --git a/backend/scripts/seed.ts b/backend/scripts/seed.ts new file mode 100644 index 0000000..855e256 --- /dev/null +++ b/backend/scripts/seed.ts @@ -0,0 +1,52 @@ +import { Client as PgClient } from 'pg'; + +async function seed() { + const pgClient = new PgClient({ + connectionString: process.env.DATABASE_URL, + }); + + await pgClient.connect(); + + console.log('Seeding database...'); + + try { + // Clear existing data + await pgClient.query('TRUNCATE artists, albums, tracks, genre, track_genre, dislikes, recommendation_batch, recommendation_batch_track, track_audio_features, track_lyrics CASCADE'); + + // Insert an artist + const artistRes = await pgClient.query( + 'INSERT INTO artists (name, mbid) VALUES ($1, $2) RETURNING id', + ['Daft Punk', '5742e173-e031-4848-90a4-977799791608'] + ); + const artistId = artistRes.rows[0].id; + + // Insert an album + const albumRes = await pgClient.query( + 'INSERT INTO albums (artist_id, title, year) VALUES ($1, $2, $3) RETURNING id', + [artistId, 'Discovery', 2001] + ); + const albumId = albumRes.rows[0].id; + + // Insert tracks + await pgClient.query( + `INSERT INTO tracks (path, hash, title, artist, album_id, duration, state, source_type) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + ['/music/daft_punk/discovery/one_more_time.mp3', 'hash1', 'One More Time', 'Daft Punk', albumId, 320, 'LIBRARY', 'MANUAL'] + ); + + await pgClient.query( + `INSERT INTO tracks (path, hash, title, artist, album_id, duration, state, source_type) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + ['/music/daft_punk/discovery/harder_better_faster_stronger.mp3', 'hash2', 'Harder, Better, Faster, Stronger', 'Daft Punk', albumId, 224, 'LIBRARY', 'MANUAL'] + ); + + console.log('Seeding successful!'); + } catch (err) { + console.error('Seeding failed:', err); + process.exit(1); + } finally { + await pgClient.end(); + } +} + +seed(); diff --git a/backend/scripts/setup-db.sh b/backend/scripts/setup-db.sh new file mode 100755 index 0000000..e660568 --- /dev/null +++ b/backend/scripts/setup-db.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Initialize the database schema +psql "$DATABASE_URL" -f src/db/schema.sql + + +echo "Database initialized successfully." diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..0f23a88 --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,185 @@ +import Fastify from 'fastify'; +import { Client as PgClient } from 'pg'; +import { createClient as createRedisClient } from 'redis'; +import { DbService } from './services/db.service.js'; +import { JobService } from './services/job.service.js'; +import { SearchService } from './services/search.service.js'; +import libraryRoutes from './routes/library.routes.js'; +import searchRoutes from './routes/search.routes.js'; +import adminRoutes from './routes/admin.routes.js'; +import vibeRoutes from './routes/vibe.routes.js'; +import historyRoutes from './routes/history.routes.js'; +import streamRoutes from './routes/stream.routes.js'; +import quarantineRoutes from './routes/quarantine.routes.js'; +import settingsRoutes from './routes/settings.routes.js'; +import graphRoutes from './routes/graph.routes.js'; +import { SessionDirector } from './services/session-director.service.js'; +import v2Routes from './routes/v2.routes.js'; +import discoveryRoutes from './routes/discovery.routes.js'; +import imagesRoutes from './routes/images.routes.js'; + +export interface AppConfig { + port: number; + searchHost: string; + searchPort: number; + searchApiKey: string; +} + +export async function buildApp(config: AppConfig) { + const fastify = Fastify({ logger: true }); + + const pgClient = new PgClient({ + connectionString: process.env.DATABASE_URL, + }); + await pgClient.connect(); + + const redisClient = createRedisClient({ + url: process.env.REDIS_URL, + }); + await redisClient.connect(); + + const jobService = new JobService({ redisUrl: process.env.REDIS_URL! }); + const searchService = new SearchService({ + host: config.searchHost, + port: config.searchPort, + protocol: 'http', + apiKey: config.searchApiKey, + }); + const dbService = new DbService(pgClient, searchService); + + // Apply the idempotent schema on boot so tables added after the initial DB + // volume was created (e.g. play_history, feedback) exist. The init-time + // docker-entrypoint mount only runs on first init, so older volumes miss them. + await dbService.ensureSchema(); + await dbService.runMigrations(); + + // Keep the claim_fusion materialised view fresh. The trigger on + // `claims` fires NOTIFY on every change; rather than maintain a + // LISTEN consumer (separate long-lived connection), we refresh on a + // short interval. 10s staleness is well below any user-facing + // latency for a homelab music player. + const FUSION_REFRESH_MS = 10_000; + const fusionTimer = setInterval(() => { + dbService.refreshClaimFusion().catch(() => {}); + }, FUSION_REFRESH_MS); + + // Daily belief decay (spec §B.4). Runs hourly; the SQL only touches + // beliefs whose last_decayed_at is >1h old, so frequent runs are safe. + const DECAY_INTERVAL_MS = 60 * 60 * 1000; + const decayTimer = setInterval(() => { + dbService.decayBeliefs().catch((e) => console.error('[DB] belief decay failed:', e)); + }, DECAY_INTERVAL_MS); + + // Nightly 'forgotten' profile derivation (spec §B.2). + const FORGOTTEN_INTERVAL_MS = 24 * 60 * 60 * 1000; + const forgottenTimer = setInterval(() => { + dbService.deriveForgottenProfile().catch((e) => + console.error('[DB] forgotten derivation failed:', e) + ); + }, FORGOTTEN_INTERVAL_MS); + + // Run both once at boot so the first session benefits. + dbService.decayBeliefs().catch(() => {}); + dbService.deriveForgottenProfile().catch(() => {}); + + // Ensure the Typesense 'tracks' collection schema exists on boot so that + // the first search request doesn't hit a 404. + await searchService.ensureCollection(); + + // Health check route + + fastify.get('/api/health', async (request, reply) => { + const status = { + postgres: 'unknown', + redis: 'unknown', + }; + + try { + await pgClient.query('SELECT 1'); + status.postgres = 'ok'; + } catch (err) { + status.postgres = 'error'; + fastify.log.error(err); + } + + try { + const redisRes = await redisClient.ping(); + if (redisRes === 'PONG') { + status.redis = 'ok'; + } + } catch (err) { + status.redis = 'error'; + fastify.log.error(err); + } + + const isHealthy = status.postgres === 'ok' && status.redis === 'ok'; + + if (isHealthy) { + return reply.code(200).send(status); + } else { + return reply.code(503).send(status); + } + }); + + fastify.register(imagesRoutes, { prefix: '/api' }); + fastify.register(libraryRoutes, { prefix: '/api', dbService }); + fastify.register(searchRoutes, { prefix: '/api', dbService }); + fastify.register(adminRoutes, { prefix: '/api/admin', jobService, dbService }); + fastify.register(vibeRoutes, { prefix: '/api/vibe', dbService }); + fastify.register(historyRoutes, { prefix: '/api', dbService }); + fastify.register(streamRoutes, { prefix: '/api', dbService }); + fastify.register(quarantineRoutes, { prefix: '/api', dbService }); + fastify.register(settingsRoutes, { prefix: '/api', dbService }); + fastify.register(graphRoutes, { prefix: '/api', dbService }); + + const sessionDirector = new SessionDirector(dbService); + + fastify.register(v2Routes, { prefix: '/api', dbService, sessionDirector }); + fastify.register(discoveryRoutes, { prefix: '/api', dbService }); + fastify.post('/api/test/enqueue-job', async (request, reply) => { + const { jobType, trackId, payload } = request.body as any; + try { + if (jobType === 'metadataRefresh') { + await jobService.enqueueMetadataRefresh(trackId, payload.type); + } else if (jobType === 'audioAnalysis') { + await jobService.enqueueAudioAnalysis(trackId, payload.features); + } else if (jobType === 'cleanup') { + await jobService.enqueueCleanup(payload.reason, payload.targetFiles); + } else { + return reply.code(400).send({ error: 'Unknown job type' }); + } + await reply.send({ message: 'Job enqueued' }); + } catch (error) { + request.log.error(error); + await reply.status(500).send({ error: 'Internal server error' }); + } + }); + + // Register hooks to close connections on shutdown + fastify.addHook('onClose', async () => { + try { + clearInterval(fusionTimer); + clearInterval(decayTimer); + clearInterval(forgottenTimer); + } catch (err) { + fastify.log.error(err); + } + try { + await pgClient.end(); + } catch (err) { + fastify.log.error(err); + } + try { + await redisClient.quit(); + } catch (err) { + fastify.log.error(err); + } + try { + await jobService.close(); + } catch (err) { + fastify.log.error(err); + } + }); + + return { fastify, pgClient, redisClient }; +} diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql new file mode 100644 index 0000000..dd49a26 --- /dev/null +++ b/backend/src/db/schema.sql @@ -0,0 +1,490 @@ +-- Enums +-- Guarded so this file is idempotent and can be re-applied on every backend boot +-- (CREATE TYPE has no IF NOT EXISTS; swallow the duplicate_object error instead). +DO $$ BEGIN + CREATE TYPE track_state AS ENUM ('LIBRARY', 'RECOMMENDED', 'HIDDEN', 'MISSING', 'DELETED'); +EXCEPTION WHEN duplicate_object THEN null; END $$; +DO $$ BEGIN + CREATE TYPE track_source_type AS ENUM ('MANUAL', 'RECOMMENDATION'); +EXCEPTION WHEN duplicate_object THEN null; END $$; +DO $$ BEGIN + CREATE TYPE recommendation_status AS ENUM ('ACTIVE', 'RESOLVED', 'FAILED'); +EXCEPTION WHEN duplicate_object THEN null; END $$; +DO $$ BEGIN + CREATE TYPE dislike_state AS ENUM ('HIDDEN', 'WARNED', 'DELETED'); +EXCEPTION WHEN duplicate_object THEN null; END $$; + +-- Reduce an artist string to its PRIMARY (first-billed) artist, so that every +-- form of a collaboration maps to the same canonical identity: +-- "Artist feat. Guest", "Artist ft. Guest", "Artist x Guest", +-- "Artist & Guest", "Artist; Guest", "Artist, Guest", "Artist / Guest" +-- all map to "Artist". This is the identity used by the normalized_name / +-- normalized_artist generated columns, dedup, and the Vibe engine. The full +-- list of co-billed artists is preserved separately in the track_artists table +-- (populated by the scanner and the split-collab-artists migration); this +-- function intentionally only yields the main artist. +-- Also handles parenthesized feature forms like "(feat. X)". +CREATE OR REPLACE FUNCTION normalize_artist(artist TEXT) RETURNS TEXT AS $$ +DECLARE + result TEXT; +BEGIN + -- 1. Strip feat/ft/vs/x feature suffixes (+ everything after them), incl. + -- parenthesized forms like "(feat. X)" / "(Feat. X)". + result := REGEXP_REPLACE( + artist, + '\s*\(?\s*([fF]eat(uring)?\.?|[fF]t\.?|[vV]s\.?|[xX])\s+.*$', + '' + ); + -- 2. Cut at the first collaboration separator ( ; & / , ) and keep the part + -- before it: "$bunny, Metox" -> "$bunny", "Booker & ЗАМАЙ" -> "Booker". + result := REGEXP_REPLACE(result, '\s*[;&/,].*$', ''); + result := BTRIM(result); + RETURN result; +END; +$$ LANGUAGE plpgsql IMMUTABLE STRICT; + +-- Tables +CREATE TABLE IF NOT EXISTS artists ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- Canonical display name (e.g., "P!nk" not "Pink") + canonical_name TEXT NOT NULL, + -- Sort name for alphabetical ordering (e.g., "Pink, P!" or "Beatles, The") + sort_name TEXT, + -- MusicBrainz ID: the canonical identity. NULL for artists not in MB. + -- Unique when present so we never create duplicate MB artists. + mbid UUID UNIQUE, + -- Fallback: legacy name used before MBID resolution. + -- Not unique; multiple rows can have same name before dedup. + name TEXT NOT NULL, + normalized_name TEXT + GENERATED ALWAYS AS (normalize_artist(name)) STORED, + discogs_id TEXT, + image_path TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_artists_mbid ON artists(mbid) WHERE mbid IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_artists_normalized_name ON artists(normalized_name); + +-- Artist aliases: alternative names for the same artist. +-- Enables matching "Pink", "P!nk", "PINK" to the same artist_id. +CREATE TABLE IF NOT EXISTS artist_aliases ( + artist_id UUID NOT NULL REFERENCES artists(id) ON DELETE CASCADE, + alias TEXT NOT NULL, + alias_normalized TEXT GENERATED ALWAYS AS (normalize_artist(alias)) STORED, + PRIMARY KEY (artist_id, alias) +); + +CREATE INDEX IF NOT EXISTS idx_artist_aliases_normalized ON artist_aliases(alias_normalized); + +-- Artist lookup cache: avoids repeated MusicBrainz queries. +-- Keyed by normalized artist name. +CREATE TABLE IF NOT EXISTS artist_lookup_cache ( + normalized_name TEXT PRIMARY KEY, + mbid UUID, + canonical_name TEXT, + sort_name TEXT, + fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + -- Track if we looked up and found nothing (negative cache) + not_found BOOLEAN DEFAULT FALSE +); + +CREATE TABLE IF NOT EXISTS albums ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + artist_id UUID REFERENCES artists(id) ON DELETE CASCADE, + title TEXT NOT NULL, + year INTEGER, + -- Full release date from MusicBrainz first-release-date (YYYY-MM-DD). + -- More precise than `year` (which can also come from Discogs). Used as a + -- deterministic tiebreaker in album dedup (earlier release = keeper). + release_date DATE, + artwork_id TEXT, + mbid UUID UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(artist_id, title) +); + +CREATE INDEX IF NOT EXISTS idx_albums_mbid ON albums(mbid) WHERE mbid IS NOT NULL; + +CREATE TABLE IF NOT EXISTS tracks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + path TEXT UNIQUE NOT NULL, + hash TEXT NOT NULL, + title TEXT NOT NULL, + artist TEXT NOT NULL, + album_id UUID REFERENCES albums(id) ON DELETE CASCADE, + duration REAL NOT NULL, + state track_state DEFAULT 'LIBRARY', + play_count INTEGER DEFAULT 0, + skip_count INTEGER DEFAULT 0, + dislike_count INTEGER DEFAULT 0, + last_played_at TIMESTAMP, + mtime REAL, + source_type track_source_type DEFAULT 'MANUAL', + quarantined_at TIMESTAMP, + deleted_at TIMESTAMP, + release_date DATE +); + +-- Add normalized columns as generated columns for existing databases where the +-- CREATE TABLE IF NOT EXISTS above was a no-op (column didn't exist before). + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'tracks' AND column_name = 'release_date' + ) THEN + ALTER TABLE tracks ADD COLUMN release_date DATE; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_tracks_release_date ON tracks (release_date) WHERE release_date IS NOT NULL; +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'tracks' AND column_name = 'normalized_artist' + ) THEN + ALTER TABLE tracks ADD COLUMN normalized_artist TEXT + GENERATED ALWAYS AS (normalize_artist(artist)) STORED; + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'artists' AND column_name = 'normalized_name' + ) THEN + ALTER TABLE artists ADD COLUMN normalized_name TEXT + GENERATED ALWAYS AS (normalize_artist(name)) STORED; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_tracks_hash ON tracks(hash); +CREATE INDEX IF NOT EXISTS idx_tracks_normalized_artist ON tracks(normalized_artist); +CREATE INDEX IF NOT EXISTS idx_artists_normalized_name ON artists(normalized_name); + +-- Tracks integrity issues found by the periodic integrity-sweep worker. +-- Created with IF NOT EXISTS so the worker can self-provision this table at +-- runtime on databases that predate this schema change (see IntegrityService.ensureSchema). +CREATE TABLE IF NOT EXISTS track_integrity_issues ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + track_id UUID REFERENCES tracks(id) ON DELETE CASCADE, + issue_type TEXT NOT NULL, -- 'CORRUPT_METADATA' | 'MISSING_FILE' + status TEXT NOT NULL DEFAULT 'OPEN', -- 'OPEN' | 'FIXED' | 'NEEDS_REVIEW' + details TEXT, -- human-readable: e.g. the corrupted value + detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + resolved_at TIMESTAMP, + UNIQUE(track_id, issue_type) +); + +-- Last.fm artist similarity, feeds Vibe discovery. Populated by the worker's +-- `artist_similarity` job (see EnrichmentService.refreshArtistSimilarity). +-- Created with IF NOT EXISTS so the worker can self-provision this table at +-- runtime on databases that predate this schema change. +-- +-- mbid storage decision: artists.mbid is typed UUID and MusicBrainz MBIDs are +-- themselves UUID-format strings, so the worker stores the artist MBID directly +-- in the existing artists.mbid column (guarded with a UUID-shape check before +-- the write). No mbid_text column was needed. +CREATE TABLE IF NOT EXISTS artist_similar ( + artist_id UUID REFERENCES artists(id) ON DELETE CASCADE, + similar_name TEXT NOT NULL, + match REAL NOT NULL DEFAULT 0, + fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (artist_id, similar_name) +); + +CREATE TABLE IF NOT EXISTS genre ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + parent_id UUID REFERENCES genre(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS track_genre ( + track_id UUID REFERENCES tracks(id) ON DELETE CASCADE, + genre_id UUID REFERENCES genre(id) ON DELETE CASCADE, + weight DECIMAL NOT NULL DEFAULT 1.0, + PRIMARY KEY (track_id, genre_id) +); + +CREATE TABLE IF NOT EXISTS dislikes ( + track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + disliked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + warned_at TIMESTAMP, + deleted_at TIMESTAMP, + grace_hours INTEGER DEFAULT 48, + state dislike_state DEFAULT 'HIDDEN' +); + +CREATE TABLE IF NOT EXISTS favorites ( + user_id UUID NOT NULL, + track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS recommendation_batch ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + status recommendation_status DEFAULT 'ACTIVE', + last_interaction_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + seed_track_id UUID REFERENCES tracks(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS recommendation_batch_track ( + batch_id UUID REFERENCES recommendation_batch(id) ON DELETE CASCADE, + track_id UUID REFERENCES tracks(id) ON DELETE CASCADE, + PRIMARY KEY (batch_id, track_id) +); + +-- Play history: one row per playback event. Feeds the Vibe engine's +-- "Success-Driven Center" rule (a completed play moves the active batch's center) +-- and the feedback learning loop. Created with IF NOT EXISTS so it can be +-- self-provisioned on databases that predate this schema change. +CREATE TABLE IF NOT EXISTS play_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + track_id UUID REFERENCES tracks(id) ON DELETE CASCADE, + batch_id UUID REFERENCES recommendation_batch(id) ON DELETE SET NULL, + completed BOOLEAN NOT NULL DEFAULT false, + played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_play_history_user_played_at ON play_history(user_id, played_at DESC); + +-- Feedback: explicit user signals consumed by the Vibe scorer's feedback +-- learning loop. action is one of 'promoted' | 'disliked' | 'skipped' | 'deleted_permanent'. +CREATE TABLE IF NOT EXISTS feedback ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + track_id UUID REFERENCES tracks(id) ON DELETE CASCADE, + action TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_feedback_user_action ON feedback(user_id, action); + +CREATE TABLE IF NOT EXISTS track_audio_features ( + track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + bpm REAL, + key TEXT, + energy REAL, + danceability REAL, + valence REAL, + acousticness REAL, + instrumentalness REAL, + liveness REAL, + valence_score REAL, + tempo REAL +); + +CREATE TABLE IF NOT EXISTS track_lyrics ( + track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + lyrics_text TEXT, + provider TEXT, + language VARCHAR(10), + synced_lyrics JSONB +); + +-- Enrichment settings: toggles that control which external-enrichment steps the +-- worker runs. Default all to true (best-effort, credentials-permitting). +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO settings (key, value) VALUES ('enrich_metadata', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('enrich_cover_art', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('enrich_genres', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('enrich_lyrics', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('enrich_artist_similarity', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('enrich_audio_analysis', 'false') ON CONFLICT (key) DO NOTHING; + + +-- ========================================================================== +-- v2 Recommendation Engine — System A: Knowledge Graph (probabilistic fusion) +-- ========================================================================== + +-- Source trust weights. One row per source of claims. Tunable. +CREATE TABLE IF NOT EXISTS source_trust ( + key TEXT PRIMARY KEY, + trust REAL NOT NULL CHECK (trust >= 0 AND trust <= 1.0), + description TEXT NOT NULL +); + +INSERT INTO source_trust (key, trust, description) VALUES + ('curated', 1.00, 'Manual / human-curated claim. Never decayed.'), + ('mb', 0.90, 'MusicBrainz structural spine. High-trust seed; not infallible.'), + ('cover_art_archive', 0.85, 'Cover Art Archive, MB-backed.'), + ('discogs', 0.75, 'Discogs release/artist credits.'), + ('lastfm', 0.50, 'Last.fm tags + similar. Noisy; used as weak signal.'), + ('listener_behavior', 0.40, 'Derived from observed play patterns. User-keyed.'), + ('tag', 0.30, 'File-tag-derived via scanner heuristic. Lowest trust.') +ON CONFLICT (key) DO NOTHING; + +-- Claims: the spine of the graph. One row per (subject, predicate, object, source). +-- user_id is NULL for objective claims (MB, Discogs, tags), non-NULL for +-- listener-behavior-derived claims. +CREATE TABLE IF NOT EXISTS claims ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID, + subject_type TEXT NOT NULL, + subject_id UUID NOT NULL, + predicate TEXT NOT NULL, + object_type TEXT NOT NULL, + object_id UUID NOT NULL, + source TEXT NOT NULL REFERENCES source_trust(key), + confidence REAL NOT NULL DEFAULT 1.0 CHECK (confidence >= 0 AND confidence <= 1.0), + evidence_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_reinforced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + raw JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (subject_type, subject_id, predicate, object_type, object_id, source, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_claims_subject ON claims (subject_type, subject_id, predicate); +CREATE INDEX IF NOT EXISTS idx_claims_object ON claims (object_type, object_id, predicate); +CREATE INDEX IF NOT EXISTS idx_claims_user ON claims (user_id) WHERE user_id IS NOT NULL; + +-- recording_mbid on tracks — the structural spine anchor for the graph +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'tracks' AND column_name = 'recording_mbid' + ) THEN + ALTER TABLE tracks ADD COLUMN recording_mbid UUID; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_tracks_recording_mbid ON tracks (recording_mbid) WHERE recording_mbid IS NOT NULL; + +-- ========================================================================== +-- System B: Listener Model +-- ========================================================================== + +-- Evidence: every observed interaction that should influence a belief. +-- Append-only. Never edited or deleted (purge policy separate). +CREATE TABLE IF NOT EXISTS evidence ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + entity_type TEXT NOT NULL, + entity_id UUID NOT NULL, + signal TEXT NOT NULL, + profile TEXT NOT NULL, + weight REAL NOT NULL, + context JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_evidence_user_entity ON evidence (user_id, entity_type, entity_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_evidence_user_profile ON evidence (user_id, profile, created_at DESC); + +-- Listener beliefs: the derived state. Continuously decayed; reinforced by evidence. +CREATE TABLE IF NOT EXISTS listener_beliefs ( + user_id UUID NOT NULL, + profile TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id UUID NOT NULL, + dimension TEXT NOT NULL, + value REAL NOT NULL CHECK (value >= -1.0 AND value <= 1.0), + confidence REAL NOT NULL CHECK (confidence >= 0 AND confidence <= 1.0), + evidence_count INTEGER NOT NULL DEFAULT 0, + last_reinforced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_decayed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, profile, entity_type, entity_id, dimension) +); + +CREATE INDEX IF NOT EXISTS idx_listener_beliefs_user_profile ON listener_beliefs (user_id, profile, entity_type, entity_id); + +-- ========================================================================== +-- System D: Session Director +-- ========================================================================== + +-- Per-session state; persisted across heartbeats so resumes stay coherent. +CREATE TABLE IF NOT EXISTS session_state ( + session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_interaction TIMESTAMPTZ NOT NULL DEFAULT NOW(), + context TEXT, + state_vector JSONB NOT NULL DEFAULT '{}'::jsonb +); + +CREATE INDEX IF NOT EXISTS idx_session_state_user ON session_state (user_id, last_interaction DESC); + +-- Diversity budgets for the session director's planner. +CREATE TABLE IF NOT EXISTS diversity_budgets ( + user_id UUID NOT NULL, + dimension TEXT NOT NULL, + budget_share REAL NOT NULL, + horizon_min INTEGER NOT NULL, + PRIMARY KEY (user_id, dimension, horizon_min) +); + +-- Adaptive minimum-distance repetition rules. +CREATE TABLE IF NOT EXISTS repetition_rules ( + user_id UUID NOT NULL, + dimension TEXT NOT NULL, + min_distance INTEGER NOT NULL, + PRIMARY KEY (user_id, dimension) +); + +-- ========================================================================== +-- System E: Acquisition Pipeline +-- ========================================================================== + +-- Discovery candidates: tracks not yet in the library, identified by E. +CREATE TABLE IF NOT EXISTS discovery_candidates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source TEXT NOT NULL, + external_id TEXT NOT NULL, + title TEXT, + artist_credit JSONB, + notes JSONB, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_eval_at TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'candidate', + UNIQUE (source, external_id) +); + +-- Probation status for acquired tracks. +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'tracks' AND column_name = 'probation_status' + ) THEN + ALTER TABLE tracks ADD COLUMN probation_status TEXT + DEFAULT 'retained' + CHECK (probation_status IN ('probation', 'retained', 'retired')); + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'tracks' AND column_name = 'probation_entered_at' + ) THEN + ALTER TABLE tracks ADD COLUMN probation_entered_at TIMESTAMPTZ; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_tracks_probation ON tracks (probation_status) WHERE probation_status = 'probation'; + +-- ========================================================================== +-- Phase 4 (preserved): Image candidates +-- ========================================================================== + +CREATE TABLE IF NOT EXISTS image_candidates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entity_type TEXT NOT NULL CHECK (entity_type IN ('artist', 'album')), + entity_id UUID NOT NULL, + source TEXT NOT NULL, + url TEXT, + width INTEGER, + verified BOOLEAN DEFAULT FALSE, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (entity_type, entity_id, source) +); + +CREATE INDEX IF NOT EXISTS idx_image_candidates_entity ON image_candidates (entity_type, entity_id); diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..02480c0 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1 @@ +console.log('Backend starting...'); diff --git a/backend/src/routes/admin.routes.ts b/backend/src/routes/admin.routes.ts new file mode 100644 index 0000000..a3d7ab1 --- /dev/null +++ b/backend/src/routes/admin.routes.ts @@ -0,0 +1,194 @@ +import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { JobService } from '../services/job.service.js'; +import { DbService } from '../services/db.service.js'; + +export default async function adminRoutes(fastify: FastifyInstance, options: { jobService: JobService; dbService: DbService }) { + const { jobService, dbService } = options; + + fastify.post('/scan', async (request: FastifyRequest, reply: FastifyReply) => { + const { directory } = request.body as { directory: string }; + if (!directory) { + return reply.code(400).send({ error: 'Directory is required' }); + } + await jobService.enqueueLibraryScan(directory); + return { status: 'Scan job enqueued', directory }; + }); + + fastify.post('/reindex-tracks', async (_request: FastifyRequest, reply: FastifyReply) => { + await jobService.enqueueReindexTracks(); + return { status: 'Reindex job enqueued' }; + }); + + fastify.post('/reprocess-artists', async (_request: FastifyRequest, reply: FastifyReply) => { + await jobService.enqueueReprocessArtists(); + return { status: 'Artist reprocessing job enqueued' }; + }); + + fastify.post('/dedup-albums', async (_request: FastifyRequest, reply: FastifyReply) => { + // Merge duplicate album rows directly (synchronous — it's just SQL, no + // external API calls). Returns the number of albums merged away. + // Tiebreaker for keeper selection: MBID > artwork > earliest release_date + // > most tracks > oldest created_at. + // + // Note: the two duplicate-detection passes (by title and by MBID) may find + // overlapping pairs; the UNION ALL in `pairs` can produce duplicates, but + // the DELETE at the end is idempotent (a loser deleted in one pair won't + // exist for the next). The folded/moved CTEs also tolerate this because + // COALESCE is idempotent and the loser row simply won't be found again. + const res = await dbService.pgClient.query<{ count: number }>(` + WITH duplicates AS ( + SELECT lower(title) AS lt, array_agg(id ORDER BY + CASE WHEN mbid IS NOT NULL THEN 0 ELSE 1 END, + CASE WHEN artwork_id IS NOT NULL AND artwork_id <> '' THEN 0 ELSE 1 END, + release_date NULLS LAST, + (SELECT COUNT(*) FROM tracks t WHERE t.album_id = albums.id) DESC, + created_at + ) AS ids + FROM albums GROUP BY lower(title) HAVING COUNT(*) > 1 + ), + mbid_dupes AS ( + SELECT mbid, array_agg(id ORDER BY + CASE WHEN artwork_id IS NOT NULL AND artwork_id <> '' THEN 0 ELSE 1 END, + release_date NULLS LAST, + created_at + ) AS ids + FROM albums WHERE mbid IS NOT NULL + GROUP BY mbid HAVING COUNT(*) > 1 + ), + pairs AS ( + SELECT ids[1] AS keep_id, unnest(ids[2:]) AS loser_id FROM duplicates + UNION + SELECT ids[1] AS keep_id, unnest(ids[2:]) AS loser_id FROM mbid_dupes + ), + -- Fold metadata from losers onto keepers (idempotent via COALESCE). + folded AS ( + UPDATE albums a SET + artwork_id = COALESCE(a.artwork_id, src.artwork_id), + year = COALESCE(a.year, src.year), + mbid = COALESCE(a.mbid, src.mbid), + release_date = COALESCE(a.release_date, src.release_date) + FROM ( + SELECT DISTINCT ON (p.loser_id) p.keep_id, lo.artwork_id, lo.year, lo.mbid, lo.release_date, p.loser_id + FROM pairs p + JOIN albums lo ON lo.id = p.loser_id + ORDER BY p.loser_id + ) AS src + WHERE a.id = src.keep_id + ), + -- Move tracks from losers to keepers. + moved AS ( + UPDATE tracks SET album_id = src.keep_id + FROM (SELECT DISTINCT keep_id, loser_id FROM pairs) AS src + WHERE tracks.album_id = src.loser_id + ), + -- Delete losers. + deleted AS ( + DELETE FROM albums + WHERE id IN (SELECT DISTINCT loser_id FROM pairs) + RETURNING 1 + ) + SELECT COUNT(*)::int AS count FROM deleted + `); + return { status: 'Albums deduplicated', merged: res.rows[0]?.count ?? 0 }; + }); + + fastify.post('/reenrich-tracks', async (_request: FastifyRequest, reply: FastifyReply) => { + // Re-enqueue metadata_refresh for every LIBRARY track without re-reading + // files from disk. This re-runs the MusicBrainz canonicalisation (artist + // names, album titles, MBIDs) and re-triggers album_cover jobs — much + // faster than a full scan when only metadata needs refreshing. + const res = await dbService.pgClient.query<{ id: string }>( + `SELECT id FROM tracks WHERE state = 'LIBRARY' ORDER BY id` + ); + const trackIds = res.rows.map((r) => r.id); + const enqueued = await jobService.enqueueMetadataRefreshBatch(trackIds); + return { status: 'Re-enrich enqueued', trackCount: enqueued }; + }); + + fastify.get('/queue-stats', async () => { + return await jobService.getQueueStats(); + }); + + fastify.get('/job-history', async (request: FastifyRequest) => { + const { limit } = request.query as { limit?: string }; + return await jobService.getJobHistory(parseInt(limit || '100', 10)); + }); + + fastify.get('/duplicates', async (request) => { + const { mode } = request.query as { mode?: string }; + return await dbService.getDuplicateGroups(mode === 'title-artist' ? 'title-artist' : 'hash'); + }); + + fastify.post('/duplicates/merge', async (request: FastifyRequest, reply: FastifyReply) => { + const { keepId, deleteIds } = request.body as { keepId: string; deleteIds: string[] }; + if (!keepId || !Array.isArray(deleteIds) || deleteIds.length === 0) { + return reply.code(400).send({ error: 'keepId and deleteIds[] are required' }); + } + await dbService.mergeDuplicates(keepId, deleteIds); + return { status: 'merged', kept: keepId, deleted: deleteIds.length }; + }); + + fastify.get('/artist-stats', async (request: FastifyRequest, reply: FastifyReply) => { + const db = dbService.pgClient; + + const total = await db.query('SELECT COUNT(*)::int AS n FROM artists'); + const withMbid = await db.query('SELECT COUNT(*)::int AS n FROM artists WHERE mbid IS NOT NULL'); + const withCanonical = await db.query('SELECT COUNT(*)::int AS n FROM artists WHERE canonical_name IS NOT NULL'); + const withSort = await db.query('SELECT COUNT(*)::int AS n FROM artists WHERE sort_name IS NOT NULL'); + const withImage = await db.query('SELECT COUNT(*)::int AS n FROM artists WHERE image_path IS NOT NULL AND image_path != \'\''); + const aliases = await db.query('SELECT COUNT(*)::int AS n FROM artist_aliases'); + const cache = await db.query('SELECT COUNT(*)::int AS n FROM artist_lookup_cache'); + + const noImage = await db.query(` + SELECT name, canonical_name, mbid, sort_name + FROM artists + WHERE image_path IS NULL OR image_path = '' + ORDER BY name + LIMIT 50 + `); + + return { + total: total.rows[0].n, + withMbid: withMbid.rows[0].n, + withCanonicalName: withCanonical.rows[0].n, + withSortName: withSort.rows[0].n, + withImage: withImage.rows[0].n, + withoutImage: total.rows[0].n - withImage.rows[0].n, + aliases: aliases.rows[0].n, + cacheSize: cache.rows[0].n, + imageCoverage: `${((withImage.rows[0].n / total.rows[0].n) * 100).toFixed(1)}%`, + artistsWithoutImage: noImage.rows, + }; + }); + + fastify.get('/artist-verify/:name', async (request: FastifyRequest, reply: FastifyReply) => { + const { name } = request.params as { name: string }; + const db = dbService.pgClient; + + const exact = await db.query( + `SELECT id, name, canonical_name, sort_name, mbid, image_path + FROM artists WHERE name = $1`, + [name] + ); + + const normalized = await db.query( + `SELECT id, name, canonical_name, sort_name, mbid, image_path + FROM artists WHERE normalize_artist(name) = normalize_artist($1)`, + [name] + ); + + const aliases = await db.query( + `SELECT a.*, ar.canonical_name as artist_canonical, ar.mbid as artist_mbid + FROM artist_aliases a + JOIN artists ar ON ar.id = a.artist_id + WHERE a.alias_normalized = normalize_artist($1)`, + [name] + ); + + return { + exactMatch: exact.rows, + normalizedMatches: normalized.rows, + aliases: aliases.rows, + }; + }); +} diff --git a/backend/src/routes/discovery.routes.ts b/backend/src/routes/discovery.routes.ts new file mode 100644 index 0000000..2b82f19 --- /dev/null +++ b/backend/src/routes/discovery.routes.ts @@ -0,0 +1,94 @@ +import { FastifyInstance } from 'fastify'; +import { DbService } from '../services/db.service.js'; +import { DiscoveryService } from '../services/discovery.service.js'; +import { ImageEnrichmentService } from '../services/image-enrichment.service.js'; + +export default async function discoveryRoutes(fastify: FastifyInstance, options: { dbService: DbService }) { + const { dbService } = options; + const discovery = new DiscoveryService(dbService); + const images = new ImageEnrichmentService(dbService); + + /** + * POST /api/discovery/walk — trigger graph walk for discovery candidates + */ + fastify.post('/discovery/walk', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const count = await discovery.walkGraphForDiscovery(userId); + return reply.send({ newCandidates: count }); + }); + + /** + * GET /api/discovery/candidates — list discovery candidates + * Query: ?status=candidate&limit=50 + */ + fastify.get('/discovery/candidates', async (request, reply) => { + const query = request.query as { status?: string; limit?: string }; + const status = query.status || 'candidate'; + const limit = parseInt(query.limit || '50', 10); + + const res = await dbService.pgClient.query( + `SELECT * FROM discovery_candidates WHERE status = $1 ORDER BY first_seen_at DESC LIMIT $2`, + [status, limit] + ); + return reply.send({ candidates: res.rows }); + }); + + /** + * POST /api/discovery/eval — evaluate pending candidates for acquisition + */ + fastify.post('/discovery/eval', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const results = await discovery.evalCandidates(userId); + return reply.send({ evaluated: results.length, results }); + }); + + /** + * POST /api/discovery/sweep-probation — evaluate probation tracks + */ + fastify.post('/discovery/sweep-probation', async (_request, reply) => { + const result = await discovery.sweepProbation(); + return reply.send(result); + }); + + /** + * POST /api/discovery/meta-learn — run meta-learning + */ + fastify.post('/discovery/meta-learn', async (_request, reply) => { + await discovery.runMetaLearning(); + return reply.send({ status: 'ok' }); + }); + + /** + * POST /api/images/fetch — mark image candidates for an entity + * Body: { entity_type, entity_id } + */ + fastify.post('/images/fetch', async (request, reply) => { + const body = request.body as { entity_type: string; entity_id: string }; + if (!body.entity_type || !body.entity_id) { + return reply.code(400).send({ error: 'entity_type and entity_id required' }); + } + + let count = 0; + if (body.entity_type === 'artist') { + count = await images.fetchImagesForArtist(body.entity_id); + } else if (body.entity_type === 'album') { + count = await images.fetchImagesForAlbum(body.entity_id); + } else { + return reply.code(400).send({ error: 'entity_type must be "artist" or "album"' }); + } + return reply.send({ candidateRows: count }); + }); + + /** + * POST /api/images/select — select best image for an entity + * Body: { entity_type, entity_id } + */ + fastify.post('/images/select', async (request, reply) => { + const body = request.body as { entity_type: string; entity_id: string }; + if (!body.entity_type || !body.entity_id) { + return reply.code(400).send({ error: 'entity_type and entity_id required' }); + } + const url = await images.selectBestImage(body.entity_type, body.entity_id); + return reply.send({ url }); + }); +} diff --git a/backend/src/routes/graph.routes.ts b/backend/src/routes/graph.routes.ts new file mode 100644 index 0000000..f185dbc --- /dev/null +++ b/backend/src/routes/graph.routes.ts @@ -0,0 +1,152 @@ +import { FastifyInstance } from 'fastify'; +import { DbService } from '../services/db.service.js'; + +export default async function graphRoutes(fastify: FastifyInstance, options: { dbService: DbService }) { + const { dbService } = options; + + /** + * GET /api/graph/artists/:id/fusion — fused artist credits for a track or album + * Query: ?entity_type=track&entity_id=<uuid> + * Returns the fused view of who is credited as main/featured on this entity. + */ + fastify.get('/graph/artists/:id/fusion', async (request, reply) => { + const { id } = request.params as { id: string }; + const query = request.query as { entity_type?: string; entity_id?: string }; + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + + if (query.entity_type === 'track' && query.entity_id) { + const artists = await dbService.getFusedTrackArtists(query.entity_id, userId); + return reply.send({ entity_type: 'track', entity_id: query.entity_id, artists }); + } + + return reply.code(400).send({ error: 'Provide ?entity_type=track&entity_id=<uuid>' }); + }); + + /** + * GET /api/graph/tracks/:id/claims — all claims for a track + */ + fastify.get('/graph/tracks/:id/claims', async (request, reply) => { + const { id } = request.params as { id: string }; + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + + const claims = await dbService.getClaimsBySubject('track', id, undefined, userId); + return reply.send({ track_id: id, claims }); + }); + + /** + * GET /api/graph/artists/:id/claims — all claims for an artist + */ + fastify.get('/graph/artists/:id/claims', async (request, reply) => { + const { id } = request.params as { id: string }; + const predicate = (request.query as { predicate?: string }).predicate; + + const claims = await dbService.getClaimsBySubject('artist', id, predicate); + return reply.send({ artist_id: id, claims }); + }); + + /** + * GET /api/graph/artists/:id/beliefs — listener beliefs for an artist + */ + fastify.get('/graph/artists/:id/beliefs', async (request, reply) => { + const { id } = request.params as { id: string }; + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + + const beliefs = await dbService.getListenerBeliefs({ + userId, + entityType: 'artist', + entityId: id, + }); + return reply.send({ artist_id: id, beliefs }); + }); + + /** + * POST /api/graph/claim — upsert a claim into the graph + * Body: { subject_type, subject_id, predicate, object_type, object_id, source, confidence?, raw? } + */ + fastify.post('/graph/claim', async (request, reply) => { + const body = request.body as { + subject_type: string; + subject_id: string; + predicate: string; + object_type: string; + object_id: string; + source: string; + confidence?: number; + raw?: unknown; + }; + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + + if (!body.subject_type || !body.subject_id || !body.predicate || !body.object_type || !body.object_id || !body.source) { + return reply.code(400).send({ error: 'Missing required fields: subject_type, subject_id, predicate, object_type, object_id, source' }); + } + + const id = await dbService.upsertClaim({ + user_id: userId === '00000000-0000-0000-0000-000000000000' ? null : userId, + subject_type: body.subject_type, + subject_id: body.subject_id, + predicate: body.predicate, + object_type: body.object_type, + object_id: body.object_id, + source: body.source, + confidence: body.confidence, + raw: body.raw, + }); + return reply.code(201).send({ id }); + }); + + /** + * POST /api/graph/evidence — record an evidence signal + * Body: { entity_type, entity_id, signal, profile, weight, context? } + */ + fastify.post('/graph/evidence', async (request, reply) => { + const body = request.body as { + entity_type: string; + entity_id: string; + signal: string; + profile: string; + weight: number; + context?: unknown; + }; + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + + if (!body.entity_type || !body.entity_id || !body.signal || body.weight === undefined) { + return reply.code(400).send({ error: 'Missing required fields: entity_type, entity_id, signal, weight' }); + } + + const id = await dbService.recordEvidence({ + user_id: userId, + entity_type: body.entity_type, + entity_id: body.entity_id, + signal: body.signal, + profile: body.profile || 'longterm', + weight: body.weight, + context: body.context, + }); + return reply.code(201).send({ id }); + }); + + /** + * GET /api/graph/sources — list all claim sources and their trust weights + */ + fastify.get('/graph/sources', async (_request, reply) => { + const res = await (dbService as any).pgClient.query( + 'SELECT * FROM source_trust ORDER BY trust DESC' + ); + return reply.send({ sources: res.rows }); + }); + + /** + * GET /api/graph/summary — aggregate graph stats (claim counts per source) + */ + fastify.get('/graph/summary', async (_request, reply) => { + const counts = await (dbService as any).pgClient.query( + `SELECT c.source, st.trust, COUNT(*)::int AS claim_count + FROM claims c + JOIN source_trust st ON st.key = c.source + GROUP BY c.source, st.trust + ORDER BY claim_count DESC` + ); + const total = counts.rows.reduce((sum: number, r: any) => sum + r.claim_count, 0); + return reply.send({ total_claims: total, by_source: counts.rows }); + }); +} diff --git a/backend/src/routes/history.routes.ts b/backend/src/routes/history.routes.ts new file mode 100644 index 0000000..4aabb98 --- /dev/null +++ b/backend/src/routes/history.routes.ts @@ -0,0 +1,52 @@ +import { FastifyInstance } from 'fastify'; +import { DbService, FEEDBACK_ACTIONS, FeedbackAction } from '../services/db.service.js'; + +export default async function historyRoutes(fastify: FastifyInstance, options: { dbService: DbService }) { + const { dbService } = options; + + // Record a playback event. completed defaults to false. + fastify.post('/history', async (request, reply) => { + const { trackId, completed, batchId } = request.body as { + trackId: string; + completed?: boolean; + batchId?: string; + }; + if (!trackId) { + return reply.code(400).send({ error: 'trackId is required' }); + } + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const historyId = await dbService.recordPlay(userId, trackId, completed === true, batchId); + return reply.send({ historyId }); + }); + + // Record a skip (transient negative signal). + fastify.post('/history/skip', async (request, reply) => { + const { trackId } = request.body as { trackId: string }; + if (!trackId) { + return reply.code(400).send({ error: 'trackId is required' }); + } + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + await dbService.recordSkip(userId, trackId); + return reply.send({ status: 'ok' }); + }); + + // Recent play history for the user. + fastify.get('/history', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + return await dbService.getHistory(userId); + }); + + // Explicit feedback. + fastify.post('/feedback', async (request, reply) => { + const { trackId, action } = request.body as { trackId: string; action: string }; + if (!trackId) { + return reply.code(400).send({ error: 'trackId is required' }); + } + if (!FEEDBACK_ACTIONS.includes(action as FeedbackAction)) { + return reply.code(400).send({ error: `Invalid action. Allowed: ${FEEDBACK_ACTIONS.join(', ')}` }); + } + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + await dbService.recordFeedback(userId, trackId, action as FeedbackAction); + return reply.send({ status: 'ok' }); + }); +} diff --git a/backend/src/routes/images.routes.ts b/backend/src/routes/images.routes.ts new file mode 100644 index 0000000..1773de3 --- /dev/null +++ b/backend/src/routes/images.routes.ts @@ -0,0 +1,49 @@ +import { FastifyInstance } from 'fastify'; + +/** + * Image proxy — fetches external artwork URLs server-side and returns them + * with aggressive caching headers so the browser never re-fetches from + * Discogs / Cover Art Archive on repeat page loads. + */ +export default async function imagesRoutes(fastify: FastifyInstance) { + fastify.get('/images/proxy', async (request, reply) => { + const { url } = request.query as { url?: string }; + if (!url) { + return reply.code(400).send({ error: 'url query parameter is required' }); + } + + // Only proxy http(s) URLs — don't be an open proxy for file:// etc. + if (!url.startsWith('http://') && !url.startsWith('https://')) { + return reply.code(400).send({ error: 'Only http/https URLs are supported' }); + } + + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + return reply.code(response.status).send({ error: `Upstream returned ${response.status}` }); + } + + const buffer = await response.arrayBuffer(); + const contentType = response.headers.get('content-type') || 'image/jpeg'; + + // Cache aggressively — artwork URLs are immutable (Discogs, Cover Art + // Archive etc. use content-addressed paths). 1 year. + return reply + .headers({ + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=31536000, immutable', + 'Content-Length': buffer.byteLength, + }) + .send(Buffer.from(buffer)); + } catch (err: any) { + if (err?.name === 'TimeoutError' || err?.code === 'UND_ERR_CONNECT_TIMEOUT') { + return reply.code(504).send({ error: 'Upstream timed out' }); + } + request.log.error({ err, url }, 'Image proxy failed'); + return reply.code(502).send({ error: 'Failed to fetch image' }); + } + }); +} diff --git a/backend/src/routes/library.routes.ts b/backend/src/routes/library.routes.ts new file mode 100644 index 0000000..8d018a7 --- /dev/null +++ b/backend/src/routes/library.routes.ts @@ -0,0 +1,185 @@ +import { FastifyInstance } from 'fastify'; +import { DbService } from '../services/db.service.js'; + +export default async function libraryRoutes(fastify: FastifyInstance, options: { dbService: DbService }) { + const { dbService } = options; + + fastify.get('/tracks', async (request, reply) => { + const query = request.query as any; + const tracks = await dbService.getTracks({ + limit: query.limit ? parseInt(query.limit) : undefined, + offset: query.offset ? parseInt(query.offset) : undefined, + sort_by: query.sort_by, + order: query.order, + search: query.search, + }); + return tracks; + }); + + fastify.get('/artists', async (request) => { + const query = request.query as any; + return await dbService.getArtists({ + limit: query.limit ? parseInt(query.limit) : undefined, + offset: query.offset ? parseInt(query.offset) : undefined, + }); + }); + + fastify.get('/artists/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const artist = await dbService.getArtistsById(id); + if (!artist) { + return reply.code(404).send({ error: 'Artist not found' }); + } + return artist; + }); + + fastify.get('/artists/:id/similar', async (request, reply) => { + const { id } = request.params as { id: string }; + const artist = await dbService.getArtistsById(id); + if (!artist) { + return reply.code(404).send({ error: 'Artist not found' }); + } + return await dbService.getSimilarArtists(id); + }); + + fastify.get('/albums', async (request) => { + const query = request.query as any; + return await dbService.getAlbums({ + limit: query.limit ? parseInt(query.limit) : undefined, + offset: query.offset ? parseInt(query.offset) : undefined, + }); + }); + + fastify.get('/albums/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const album = await dbService.getAlbumById(id); + if (!album) { + return reply.code(404).send({ error: 'Album not found' }); + } + return album; + }); + + // Genres + fastify.get('/genres', async () => { + return await dbService.getGenres(); + }); + + fastify.get('/genres/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const genre = await dbService.getGenreById(id); + if (!genre) { + return reply.code(404).send({ error: 'Genre not found' }); + } + return genre; + }); + + fastify.get('/genres/:id/tracks', async (request, reply) => { + const { id } = request.params as { id: string }; + const query = request.query as any; + return await dbService.getTracksByGenre( + id, + query.limit ? parseInt(query.limit) : undefined, + query.offset ? parseInt(query.offset) : undefined + ); + }); + + // Favorites + fastify.get('/favorites', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + return await dbService.getFavorites(userId); + }); + + fastify.post('/favorites/:trackId', async (request, reply) => { + const { trackId } = request.params as { trackId: string }; + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + await dbService.addFavorite(userId, trackId); + return reply.send({ status: 'added' }); + }); + + fastify.delete('/favorites/:trackId', async (request, reply) => { + const { trackId } = request.params as { trackId: string }; + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + await dbService.removeFavorite(userId, trackId); + return reply.send({ status: 'removed' }); + }); + + // Dislikes + fastify.post('/tracks/:trackId/dislike', async (request, reply) => { + const { trackId } = request.params as { trackId: string }; + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + await dbService.dislikeTrack(userId, trackId); + return reply.send({ status: 'disliked' }); + }); + + // Artists CRUD + fastify.post('/artists', async (request, reply) => { + const artist = await dbService.createArtist(request.body as any); + return reply.code(201).send(artist); + }); + + fastify.put('/artists/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const artist = await dbService.updateArtist(id, request.body as any); + return artist; + }); + + fastify.delete('/artists/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + await dbService.deleteArtist(id); + return reply.send({ status: 'deleted' }); + }); + + // Albums CRUD + fastify.post('/albums', async (request, reply) => { + const album = await dbService.createAlbum(request.body as any); + return reply.code(201).send(album); + }); + + fastify.put('/albums/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const album = await dbService.updateAlbum(id, request.body as any); + return album; + }); + + fastify.delete('/albums/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + await dbService.deleteAlbum(id); + return reply.send({ status: 'deleted' }); + }); + + // Tracks CRUD + fastify.get('/tracks/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const track = await dbService.getTrackById(id); + if (!track) { + return reply.code(404).send({ error: 'Track not found' }); + } + return track; + }); + + fastify.get('/tracks/:id/lyrics', async (request, reply) => { + const { id } = request.params as { id: string }; + const lyrics = await dbService.getTrackLyrics(id); + if (!lyrics) { + return reply.code(404).send({ error: 'No lyrics found' }); + } + return lyrics; + }); + + fastify.post('/tracks', async (request, reply) => { + const track = await dbService.createTrack(request.body as any); + return reply.code(201).send(track); + }); + + fastify.put('/tracks/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const track = await dbService.updateTrack(id, request.body as any); + return track; + }); + + fastify.delete('/tracks/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + await dbService.deleteTrack(id); + return reply.send({ status: 'deleted' }); + }); +} diff --git a/backend/src/routes/quarantine.routes.ts b/backend/src/routes/quarantine.routes.ts new file mode 100644 index 0000000..200f812 --- /dev/null +++ b/backend/src/routes/quarantine.routes.ts @@ -0,0 +1,34 @@ +import { FastifyInstance } from 'fastify'; +import { DbService } from '../services/db.service.js'; + +export default async function quarantineRoutes(fastify: FastifyInstance, options: { dbService: DbService }) { + const { dbService } = options; + + // List all disliked tracks (HIDDEN + WARNED states) + fastify.get('/dislikes', async () => { + return await dbService.getDislikedTracks(); + }); + + // Restore a disliked track back to LIBRARY + fastify.post('/dislikes/:trackId/restore', async (request, reply) => { + const { trackId } = request.params as { trackId: string }; + const entry = await dbService.getDislikeByTrackId(trackId); + if (!entry) { + return reply.code(404).send({ error: 'Dislike record not found' }); + } + await dbService.restoreDislike(trackId); + return reply.send({ status: 'restored' }); + }); + + // Hard-delete a disliked track immediately (skips grace period) + fastify.delete('/dislikes/:trackId', async (request, reply) => { + const { trackId } = request.params as { trackId: string }; + const entry = await dbService.getDislikeByTrackId(trackId); + if (!entry) { + return reply.code(404).send({ error: 'Dislike record not found' }); + } + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + await dbService.permanentlyDeleteTrack(userId, trackId, entry.track_path); + return reply.send({ status: 'deleted' }); + }); +} diff --git a/backend/src/routes/search.routes.ts b/backend/src/routes/search.routes.ts new file mode 100644 index 0000000..068f6de --- /dev/null +++ b/backend/src/routes/search.routes.ts @@ -0,0 +1,21 @@ +import { FastifyInstance } from 'fastify'; +import { DbService } from '../services/db.service.js'; + +export default async function searchRoutes(fastify: FastifyInstance, options: { dbService: DbService }) { + const { dbService } = options; + + fastify.get('/search', async (request, reply) => { + const query = (request.query as any).q; + if (!query) { + return reply.code(400).send({ error: 'Query parameter "q" is required' }); + } + + try { + // Typesense-first with a Postgres ILIKE fallback (Typesense isn't indexed yet). + return await dbService.searchTracks(String(query)); + } catch (error) { + request.log.error(error); + return reply.code(500).send({ error: 'Search failed' }); + } + }); +} diff --git a/backend/src/routes/settings.routes.ts b/backend/src/routes/settings.routes.ts new file mode 100644 index 0000000..f62ce7b --- /dev/null +++ b/backend/src/routes/settings.routes.ts @@ -0,0 +1,53 @@ +import { FastifyInstance } from 'fastify'; +import { DbService } from '../services/db.service.js'; + +const SETTING_KEYS = [ + 'enrich_metadata', + 'enrich_cover_art', + 'enrich_genres', + 'enrich_lyrics', + 'enrich_artist_similarity', + 'enrich_audio_analysis', +] as const; + +type SettingKey = typeof SETTING_KEYS[number]; + +export default async function settingsRoutes(fastify: FastifyInstance, options: { dbService: DbService }) { + const { dbService } = options; + + // GET /api/settings — return all settings as { key: value } map. + fastify.get('/settings', async () => { + const rows = await dbService.pgClient.query('SELECT key, value FROM settings'); + const map: Record<string, string> = {}; + for (const row of rows.rows) { + map[row.key] = row.value; + } + return map; + }); + + // PUT /api/settings/:key — update one setting. + // Validates the key against known keys and the value as 'true'/'false'. + fastify.put<{ Params: { key: string }; Body: { value: string } }>( + '/settings/:key', + async (request, reply) => { + const { key } = request.params; + const { value } = request.body; + + if (!SETTING_KEYS.includes(key as SettingKey)) { + return reply.code(400).send({ error: `Unknown setting: ${key}` }); + } + if (value !== 'true' && value !== 'false') { + return reply.code(400).send({ error: 'Value must be "true" or "false"' }); + } + + await dbService.pgClient.query( + `INSERT INTO settings (key, value, updated_at) + VALUES ($1, $2, NOW()) + ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`, + [key, value] + ); + + return { status: 'ok', key, value }; + } + ); +} diff --git a/backend/src/routes/stream.routes.ts b/backend/src/routes/stream.routes.ts new file mode 100644 index 0000000..90d8893 --- /dev/null +++ b/backend/src/routes/stream.routes.ts @@ -0,0 +1,140 @@ +import { FastifyInstance } from 'fastify'; +import { createReadStream } from 'fs'; +import { stat } from 'fs/promises'; +import path from 'path'; +import { DbService } from '../services/db.service.js'; + +// Music library root on disk. The worker scanner writes absolute file paths into +// tracks.path rooted here; the backend container must mount the same path so they +// resolve. Path-traversal guard below verifies the resolved file stays inside. +const MUSIC_DIR = path.resolve(process.env.MUSIC_DIR || '/mnt/hdd1/media/Music'); + +const CONTENT_TYPES: Record<string, string> = { + '.mp3': 'audio/mpeg', + '.flac': 'audio/flac', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav', + '.ogg': 'audio/ogg', +}; + +function contentTypeFor(filePath: string): string { + return CONTENT_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream'; +} + +// True when resolvedPath is the music root itself or a descendant of it. +function isInsideRoot(resolvedPath: string, root: string): boolean { + return resolvedPath === root || resolvedPath.startsWith(root + path.sep); +} + +export default async function streamRoutes( + fastify: FastifyInstance, + options: { dbService: DbService } +) { + const { dbService } = options; + + fastify.get('/tracks/:id/stream', async (request, reply) => { + const { id } = request.params as { id: string }; + + const track = await dbService.getTrackById(id); + if (!track) { + return reply.code(404).send({ error: 'Track not found' }); + } + + // SECURITY: resolve the path and confirm it stays within MUSIC_DIR. This + // rejects relative paths, symlink-style escapes and any path outside root. + const resolvedPath = path.resolve(track.path); + if (!isInsideRoot(resolvedPath, MUSIC_DIR)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + let fileSize: number; + try { + const stats = await stat(resolvedPath); + if (!stats.isFile()) { + return reply.code(404).send({ error: 'File not found' }); + } + fileSize = stats.size; + } catch (err: any) { + if (err && err.code === 'ENOENT') { + // File missing on disk; the integrity worker would flag this track MISSING. + return reply.code(404).send({ error: 'File not found on disk' }); + } + throw err; + } + + const contentType = contentTypeFor(resolvedPath); + const rangeHeader = request.headers.range; + + // No Range header: stream the whole file with a 200. + if (!rangeHeader) { + reply + .code(200) + .header('Content-Type', contentType) + .header('Content-Length', fileSize) + .header('Accept-Ranges', 'bytes'); + const stream = createReadStream(resolvedPath); + stream.on('error', (err) => { + request.log.error(err); + reply.raw.destroy(err); + }); + return reply.send(stream); + } + + // Parse "bytes=start-end". Either bound may be omitted. + const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim()); + if (!match || (match[1] === '' && match[2] === '')) { + return reply + .code(416) + .header('Content-Range', `bytes */${fileSize}`) + .send({ error: 'Invalid range' }); + } + + let start: number; + let end: number; + if (match[1] === '') { + // suffix range: last N bytes + const suffixLength = parseInt(match[2], 10); + if (suffixLength <= 0) { + return reply + .code(416) + .header('Content-Range', `bytes */${fileSize}`) + .send({ error: 'Unsatisfiable range' }); + } + start = Math.max(fileSize - suffixLength, 0); + end = fileSize - 1; + } else { + start = parseInt(match[1], 10); + end = match[2] === '' ? fileSize - 1 : parseInt(match[2], 10); + } + + if (end > fileSize - 1) end = fileSize - 1; + + if ( + Number.isNaN(start) || + Number.isNaN(end) || + start > end || + start < 0 || + start >= fileSize + ) { + return reply + .code(416) + .header('Content-Range', `bytes */${fileSize}`) + .send({ error: 'Unsatisfiable range' }); + } + + const chunkSize = end - start + 1; + reply + .code(206) + .header('Content-Type', contentType) + .header('Content-Range', `bytes ${start}-${end}/${fileSize}`) + .header('Accept-Ranges', 'bytes') + .header('Content-Length', chunkSize); + + const stream = createReadStream(resolvedPath, { start, end }); + stream.on('error', (err) => { + request.log.error(err); + reply.raw.destroy(err); + }); + return reply.send(stream); + }); +} diff --git a/backend/src/routes/v2.routes.ts b/backend/src/routes/v2.routes.ts new file mode 100644 index 0000000..78b5f9f --- /dev/null +++ b/backend/src/routes/v2.routes.ts @@ -0,0 +1,143 @@ +import { FastifyInstance } from 'fastify'; +import { createClient, RedisClientType } from 'redis'; +import { DbService } from '../services/db.service.js'; +import { SessionDirector } from '../services/session-director.service.js'; +import { Candidate } from '../services/generators.service.js'; + +interface ActivePlan { + sessionId: string; + plan: Candidate[]; + seedTrackId: string | null; +} + +const PLAN_TTL_SEC = 2 * 3600; + +function planKey(userId: string): string { + return `v2:plan:${userId}`; +} + +export default async function v2Routes(fastify: FastifyInstance, options: { dbService: DbService; sessionDirector: SessionDirector }) { + const { dbService, sessionDirector: director } = options; + + const redisClient: RedisClientType = createClient({ + url: process.env.REDIS_URL || 'redis://localhost:6379', + }); + await redisClient.connect(); + fastify.addHook('onClose', async () => { await redisClient.quit(); }); + + async function getActivePlan(userId: string): Promise<ActivePlan | null> { + const raw = await redisClient.get(planKey(userId)); + if (!raw) return null; + return JSON.parse(raw) as ActivePlan; + } + + async function setActivePlan(userId: string, plan: ActivePlan): Promise<void> { + await redisClient.setEx(planKey(userId), PLAN_TTL_SEC, JSON.stringify(plan)); + } + + async function delActivePlan(userId: string): Promise<void> { + await redisClient.del(planKey(userId)); + } + + /** + * POST /api/v2/vibe/start — start a v2 session + * Body: { seedTrackId? } + * Returns: { sessionId, plan: Candidate[] } + */ + fastify.post('/v2/vibe/start', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const { seedTrackId } = request.body as { seedTrackId?: string }; + + const sessionId = await dbService.createSessionState(userId, undefined, { energy: 0.5, novelty_hunger: 0.3 }); + const plan = await director.buildPlan(userId, sessionId, seedTrackId); + + await setActivePlan(userId, { sessionId, plan, seedTrackId: seedTrackId ?? null }); + return reply.send({ sessionId, plan: plan.slice(0, 10) }); + }); + + /** + * GET /api/v2/vibe/next — get next track from the plan + * Returns: { track, planRemaining } + */ + fastify.get('/v2/vibe/next', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const active = await getActivePlan(userId); + + if (!active || active.plan.length === 0) { + return reply.code(404).send({ error: 'No active plan. POST /api/v2/vibe/start first.' }); + } + + const next = active.plan.shift()!; + // Enrich with track details + const track = await dbService.getTrackById(next.trackId); + + // Replan if running low + if (active.plan.length < 5) { + const refill = await director.replan(userId, active.sessionId, active.plan, [next.trackId], active.seedTrackId ?? undefined); + active.plan.push(...refill); + } + + await setActivePlan(userId, active); + + return reply.send({ track, explanation: next.explanation, planRemaining: active.plan.length }); + }); + + /** + * POST /api/v2/vibe/feedback — feedback that triggers replan + * Body: { trackId, action: 'completed' | 'skipped' | 'promoted' | 'disliked' } + */ + fastify.post('/v2/vibe/feedback', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const { trackId, action } = request.body as { trackId: string; action: string }; + + if (!trackId || !action) { + return reply.code(400).send({ error: 'trackId and action are required' }); + } + + // Route to existing handlers for evidence wiring + if (action === 'completed') { + await dbService.recordPlay(userId, trackId, true); + } else if (action === 'skipped') { + await dbService.recordSkip(userId, trackId); + } else if (action === 'promoted') { + await dbService.addFavorite(userId, trackId); + await dbService.recordFeedback(userId, trackId, 'promoted'); + } else if (action === 'disliked') { + await dbService.dislikeTrack(userId, trackId); + } + + // Replan the session + const active = await getActivePlan(userId); + if (active) { + const playedTrackIds = [trackId]; + const refill = await director.replan(userId, active.sessionId, active.plan, playedTrackIds, active.seedTrackId ?? undefined); + active.plan.push(...refill); + await setActivePlan(userId, active); + } + + return reply.send({ status: 'ok', planRemaining: active?.plan.length ?? 0 }); + }); + + /** + * GET /api/v2/vibe/plan — current plan for debugging + */ + fastify.get('/v2/vibe/plan', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const active = await getActivePlan(userId); + if (!active) return reply.send({ plan: [] }); + return reply.send({ sessionId: active.sessionId, planRemaining: active.plan.length, plan: active.plan }); + }); + + /** + * GET /api/v2/state — current listener state (debugging) + */ + fastify.get('/v2/state', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const state = await director.buildState(userId); + const fatigue = await director.computeFatigue(userId); + const budgets = await director.getBudgets(userId); + return reply.send({ state, fatigue: Object.fromEntries( + Object.entries(fatigue).map(([k, v]) => [k, v instanceof Map ? Object.fromEntries(v) : v]) + ), budgets }); + }); +} diff --git a/backend/src/routes/vibe.routes.ts b/backend/src/routes/vibe.routes.ts new file mode 100644 index 0000000..50632fd --- /dev/null +++ b/backend/src/routes/vibe.routes.ts @@ -0,0 +1,76 @@ +import { FastifyInstance } from 'fastify'; +import { DbService } from '../services/db.service.js'; + +export default async function vibeRoutes(fastify: FastifyInstance, options: { dbService: DbService }) { + const { dbService } = options; + + // Start a new vibe session + fastify.post('/start', async (request, reply) => { + const { seedTrackId } = request.body as { seedTrackId: string }; + if (!seedTrackId) { + return reply.code(400).send({ error: 'seedTrackId is required' }); + } + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const batchId = await dbService.createVibeSession(userId, seedTrackId); + return reply.send({ batchId }); + }); + + // Get the next chunk of tracks for the active session + fastify.get('/next', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const activeSession = await dbService.getActiveVibeSession(userId); + + if (!activeSession) { + return reply.code(404).send({ error: 'No active vibe session found' }); + } + + const tracks = await dbService.getNextVibeChunk(activeSession.batchId); + + // Update the session timestamp to keep it alive + await dbService.updateVibeSession(activeSession.batchId); + + return tracks; + }); + + // Start/return a chunk seeded by a genre (id or name) — no active session required. + fastify.get('/from-genre', async (request, reply) => { + const { genre } = request.query as { genre?: string }; + if (!genre) { + return reply.code(400).send({ error: 'genre query param is required' }); + } + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const tracks = await dbService.getVibeChunkFromGenre(genre, userId); + return tracks; + }); + + // Current ACTIVE batch metadata for the user, or 404. + fastify.get('/current', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const session = await dbService.getCurrentVibeSession(userId); + if (!session) { + return reply.code(404).send({ error: 'No active vibe session found' }); + } + return reply.send(session); + }); + + // Heartbeat: keep the active batch alive by bumping last_interaction_at. + fastify.post('/heartbeat', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const updated = await dbService.heartbeatVibeSession(userId); + if (!updated) { + return reply.code(404).send({ error: 'No active vibe session found' }); + } + return reply.send({ status: 'ok' }); + }); + + // End the vibe session + fastify.post('/end', async (request, reply) => { + const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000'; + const activeSession = await dbService.getActiveVibeSession(userId); + + if (activeSession) { + await dbService.endVibeSession(activeSession.batchId); + } + return reply.send({ status: 'session_ended' }); + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..3055f41 --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,21 @@ +import { buildApp } from './app.js'; + +const port = parseInt(process.env.PORT || '3000', 10); + +async function start() { + try { + const { fastify } = await buildApp({ + port, + searchHost: process.env.TYPESENSE_HOST || 'search', + searchPort: parseInt(process.env.TYPESENSE_PORT || '8108', 10), + searchApiKey: process.env.TYPESENSE_API_KEY || 'muzick-key' + }); + await fastify.listen({ port, host: '0.0.0.0' }); + console.log(`Server listening at http://localhost:${port}`); + } catch (err) { + console.error(err); + process.exit(1); + } +} + +start(); diff --git a/backend/src/services/db.service.test.ts b/backend/src/services/db.service.test.ts new file mode 100644 index 0000000..5ce99ad --- /dev/null +++ b/backend/src/services/db.service.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, vi } from 'vitest'; +import { DbService } from './db.service.js'; + +function makeService(): { service: DbService; mockQuery: ReturnType<typeof vi.fn> } { + const mockQuery = vi.fn(); + const service = new DbService({ query: mockQuery } as any); + return { service, mockQuery }; +} + +describe('DbService v2 methods', () => { + describe('upsertClaim', () => { + it('calls INSERT ... ON CONFLICT with correct parameters', async () => { + const { service, mockQuery } = makeService(); + mockQuery.mockResolvedValue({ rows: [{ id: 'claim-1' }] }); + + const id = await service.upsertClaim({ + subject_type: 'track', + subject_id: 'track-1', + predicate: 'credited_main_on', + object_type: 'artist', + object_id: 'artist-1', + source: 'mb', + confidence: 1.0, + }); + + expect(id).toBe('claim-1'); + expect(mockQuery).toHaveBeenCalledTimes(1); + const [sql, params] = mockQuery.mock.calls[0]; + expect(sql).toContain('INSERT INTO claims'); + expect(sql).toContain('ON CONFLICT'); + expect(params).toContain('track'); + expect(params).toContain('track-1'); + expect(params).toContain('credited_main_on'); + }); + + it('handles user_id null for objective claims', async () => { + const { service, mockQuery } = makeService(); + mockQuery.mockResolvedValue({ rows: [{ id: 'c1' }] }); + await service.upsertClaim({ + subject_type: 'artist', subject_id: 'a1', predicate: 'alias_of', + object_type: 'artist', object_id: 'a2', source: 'listener_behavior', + user_id: 'user-1', + }); + const params = mockQuery.mock.calls[0][1]; + expect(params[0]).toBe('user-1'); + }); + }); + + describe('getClaimsBySubject', () => { + it('filters by subject type and id', async () => { + const { service, mockQuery } = makeService(); + mockQuery.mockResolvedValue({ rows: [] }); + await service.getClaimsBySubject('track', 'track-1'); + const [sql, params] = mockQuery.mock.calls[0]; + expect(sql).toContain('subject_type = $1'); + expect(sql).toContain('subject_id = $2'); + expect(params).toEqual(['track', 'track-1']); + }); + + it('optionally filters by predicate and user_id', async () => { + const { service, mockQuery } = makeService(); + mockQuery.mockResolvedValue({ rows: [] }); + await service.getClaimsBySubject('artist', 'a1', 'alias_of', 'user-1'); + const [sql] = mockQuery.mock.calls[0]; + expect(sql).toContain('predicate'); + expect(sql).toContain('user_id IS NULL'); + }); + }); + + describe('recordEvidence', () => { + it('appends evidence row', async () => { + const { service, mockQuery } = makeService(); + mockQuery.mockResolvedValue({ rows: [{ id: 'ev-1' }] }); + const id = await service.recordEvidence({ + user_id: 'user-1', entity_type: 'track', entity_id: 'track-1', + signal: 'playback_completed', profile: 'longterm', weight: 0.10, + }); + expect(id).toBe('ev-1'); + const [sql] = mockQuery.mock.calls[0]; + expect(sql).toContain('INSERT INTO evidence'); + }); + }); + + describe('updateListenerBelief', () => { + it('UPSERTs with delta formula', async () => { + const { service, mockQuery } = makeService(); + mockQuery.mockResolvedValue({ rowCount: 1 }); + await service.updateListenerBelief({ + user_id: 'user-1', profile: 'longterm', + entity_type: 'track', entity_id: 'track-1', + dimension: 'affinity', value_delta: 0.10, + }); + const [sql] = mockQuery.mock.calls[0]; + expect(sql).toContain('INSERT INTO listener_beliefs'); + expect(sql).toContain('ON CONFLICT'); + expect(sql).toContain('GREATEST(-1.0, LEAST(1.0'); + }); + }); + + describe('recordEvidence → belief derivation wiring', () => { + it('derives a fresh longterm affinity belief after playback_completed', async () => { + const { service, mockQuery } = makeService(); + // first call: INSERT evidence → returns id; second call: UPSERT belief → rowCount 1 + mockQuery + .mockResolvedValueOnce({ rows: [{ id: 'ev-1' }] }) + .mockResolvedValueOnce({ rowCount: 1 }); + + const id = await service.recordEvidence({ + user_id: 'user-1', entity_type: 'track', entity_id: 'track-1', + signal: 'playback_completed', profile: 'longterm', weight: 0.10, + }); + + expect(id).toBe('ev-1'); + expect(mockQuery).toHaveBeenCalledTimes(2); + + // First call INSERTs the evidence row. + const [evidSql, evidParams] = mockQuery.mock.calls[0]; + expect(evidSql).toContain('INSERT INTO evidence'); + expect(evidParams[4]).toBe('longterm'); // profile + expect(evidParams[5]).toBe(0.10); // weight + + // Second call UPSERTs the matching listener_belief. For a fresh + // belief the INSERT path sets value = weight directly (per spec §B.4 + // INSERT branch), so the resulting row has value=0.10, confidence=0.05. + const [beliefSql, beliefParams] = mockQuery.mock.calls[1]; + expect(beliefSql).toContain('INSERT INTO listener_beliefs'); + expect(beliefSql).toContain('ON CONFLICT'); + // [user_id, profile, entity_type, entity_id, dimension, value_delta, confidence_delta] + expect(beliefParams[0]).toBe('user-1'); + expect(beliefParams[1]).toBe('longterm'); + expect(beliefParams[2]).toBe('track'); + expect(beliefParams[3]).toBe('track-1'); + expect(beliefParams[4]).toBe('affinity'); + expect(beliefParams[5]).toBe(0.10); // value_delta = weight + expect(beliefParams[6]).toBe(0.05); // confidence_delta default + }); + + it('maps play_of_never_seen to the novelty_tolerance dimension', async () => { + const { service, mockQuery } = makeService(); + mockQuery + .mockResolvedValueOnce({ rows: [{ id: 'ev-2' }] }) + .mockResolvedValueOnce({ rowCount: 1 }); + + await service.recordEvidence({ + user_id: 'user-1', entity_type: 'track', entity_id: 'track-2', + signal: 'play_of_never_seen', profile: 'discovery', weight: 0.05, + }); + + const beliefParams = mockQuery.mock.calls[1][1] as unknown[]; + expect(beliefParams[4]).toBe('novelty_tolerance'); + expect(beliefParams[1]).toBe('discovery'); + expect(beliefParams[5]).toBe(0.05); + }); + + it('still appends an evidence row before deriving the belief', async () => { + const { service, mockQuery } = makeService(); + mockQuery + .mockResolvedValueOnce({ rows: [{ id: 'ev-3' }] }) + .mockResolvedValueOnce({ rowCount: 1 }); + const id = await service.recordEvidence({ + user_id: 'user-1', entity_type: 'track', entity_id: 'track-3', + signal: 'skip_quick', profile: 'negative', weight: -0.20, + }); + expect(id).toBe('ev-3'); + expect(mockQuery.mock.calls[0][0]).toContain('INSERT INTO evidence'); + expect(mockQuery.mock.calls[1][0]).toContain('listener_beliefs'); + expect(mockQuery.mock.calls[1][1][4]).toBe('affinity'); + }); + }); + + describe('getFusedTrackArtists', () => { + it('reads from claim_fusion view', async () => { + const { service, mockQuery } = makeService(); + mockQuery.mockResolvedValue({ rows: [{ id: 'a1', name: 'Artist 1', role: 'main', confidence: 0.9 }] }); + const result = await service.getFusedTrackArtists('track-1'); + const [sql] = mockQuery.mock.calls[0]; + expect(sql).toContain('claim_fusion'); + expect(result).toHaveLength(1); + expect(result[0].role).toBe('main'); + }); + }); +}); diff --git a/backend/src/services/db.service.ts b/backend/src/services/db.service.ts new file mode 100644 index 0000000..98cc8e0 --- /dev/null +++ b/backend/src/services/db.service.ts @@ -0,0 +1,2284 @@ +import { readFile, unlink } from 'fs/promises'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { Client as PgClient } from 'pg'; +import { SearchService } from './search.service.js'; + +export interface Artist { + id: string; + name: string; + mbid?: string | null; + discogs_id?: string | null; + image_path?: string | null; +} + +export interface Album { + id: string; + artist_id: string; + title: string; + year?: number | null; + artwork_id?: string | null; +} + +export interface TrackArtist { + id: string; + name: string; + role: 'main' | 'featured'; +} + +export interface Track { + id: string; + path: string; + hash: string; + title: string; + artist: string; + album_id: string; + duration: number; + state: string; + play_count: number; + skip_count: number; + dislike_count: number; + last_played_at?: Date | null; + mtime?: number | null; + source_type: string; + artists?: TrackArtist[]; +} + +export const FEEDBACK_ACTIONS = ['promoted', 'disliked', 'skipped', 'deleted_permanent'] as const; +export type FeedbackAction = (typeof FEEDBACK_ACTIONS)[number]; + +export interface HistoryEntry extends Track { + history_id: string; + batch_id: string | null; + played_at: Date; + completed: boolean; +} + +export interface Genre { + id: string; + name: string; + parent_id?: string | null; + track_count?: number; +} + +export interface DislikeEntry { + track_id: string; + disliked_at: Date; + warned_at: Date | null; + deleted_at: Date | null; + grace_hours: number; + state: string; // 'HIDDEN' | 'WARNED' | 'DELETED' + track_title: string; + track_artist: string; + track_path: string; +} + +export interface ArtistWithAlbums { + id: string; + name: string; + mbid?: string | null; + discogs_id?: string | null; + image_path?: string | null; + albums: Album[]; +} + +export interface AlbumWithTracks { + id: string; + artist_id: string; + title: string; + year?: number | null; + artwork_id?: string | null; + tracks: Track[]; +} + + +// --------------------------------------------------------------------------- +// v2 Recommendation Engine types +// --------------------------------------------------------------------------- + +export interface Claim { + id: string; + user_id: string | null; + subject_type: string; + subject_id: string; + predicate: string; + object_type: string; + object_id: string; + source: string; + confidence: number; + evidence_at: Date; + last_reinforced_at: Date; + raw: unknown | null; + created_at: Date; +} + +export interface Evidence { + id: string; + user_id: string; + entity_type: string; + entity_id: string; + signal: string; + profile: string; + weight: number; + context: unknown | null; + created_at: Date; +} + +export interface ListenerBelief { + user_id: string; + profile: string; + entity_type: string; + entity_id: string; + dimension: string; + value: number; + confidence: number; + evidence_count: number; + last_reinforced_at: Date; + last_decayed_at: Date; +} + +export interface ClaimEdge { + subjectType: string; + subjectId: string; + predicate: string; + objectType: string; + objectId: string; + fusedValue: number; +} + +export interface SessionState { + session_id: string; + user_id: string; + started_at: Date; + last_interaction: Date; + context: string | null; + state_vector: Record<string, unknown>; +} + +export interface DiversityBudget { + user_id: string; + dimension: string; + budget_share: number; + horizon_min: number; +} + +export interface RepetitionRule { + user_id: string; + dimension: string; + min_distance: number; +} + +// --------------------------------------------------------------------------- +// Migrations registry +// Add new entries at the END. Never edit or remove existing entries. +// Convention for id: "YYYYMMDD_short_description" +// --------------------------------------------------------------------------- +const MIGRATIONS: { id: string; sql: string }[] = [ + { + id: '20260608_track_artists', + sql: ` + CREATE TABLE IF NOT EXISTS track_artists ( + track_id UUID REFERENCES tracks(id) ON DELETE CASCADE, + artist_id UUID REFERENCES artists(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'main', + PRIMARY KEY (track_id, artist_id, role) + ); + CREATE INDEX IF NOT EXISTS idx_track_artists_artist ON track_artists(artist_id); + + -- Backfill main artist from albums for tracks not yet in track_artists + INSERT INTO track_artists (track_id, artist_id, role) + SELECT t.id, al.artist_id, 'main' + FROM tracks t + JOIN albums al ON al.id = t.album_id + WHERE NOT EXISTS ( + SELECT 1 FROM track_artists ta WHERE ta.track_id = t.id + ); + `, + }, + { + id: '20260608_clear_lastfm_placeholder_images', + sql: ` + UPDATE artists SET image_path = NULL + WHERE image_path LIKE '%2a96cbd8b46e442fc41c2b86b821562f%'; + `, + }, + { + // normalize_artist() was extended (schema.sql) to also split collaboration + // separators ( ; & / ) — not just commas/feat. STORED generated columns are + // NOT recomputed when the function definition changes, so force a recompute + // by touching the base column of every dependent row. ensureSchema() (which + // installs the new function) runs before migrations, so the new definition + // is already active here. + id: '20260612_recompute_normalized_artist', + sql: ` + UPDATE artists SET name = name; + UPDATE tracks SET artist = artist; + `, + }, + { + // The name-based Wikimedia Commons image fallback (now removed from the + // enrichment chain) frequently attached the wrong photo. Clear those rows so + // they fall back to the placeholder / a better source. Verified Wikidata + // images (fetched via MBID, step 3) also live on wikimedia.org but only on + // artists that HAVE an mbid, so restricting to mbid IS NULL spares them. + id: '20260612_clear_namebased_wikimedia_images', + sql: ` + UPDATE artists SET image_path = NULL + WHERE mbid IS NULL AND image_path LIKE '%wikimedia.org%'; + `, + }, + { + // claim_fusion materialised view + compatibility views for v2 graph. + // Depends on claims, source_trust tables which are created by schema.sql + // (run before migrations). The MV resolves truth at read time as a weighted + // vote across claims per the fusion formula in spec §A.4. + id: '20260707_claim_fusion', + sql: ` + CREATE OR REPLACE VIEW claim_fusion AS + SELECT + c.subject_type, + c.subject_id, + c.predicate, + c.object_type, + c.object_id, + COALESCE(c.user_id, '00000000-0000-0000-0000-000000000000'::uuid) AS user_id, + SUM( + st.trust * c.confidence * + GREATEST(0.1, 1.0 - EXTRACT(DAY FROM NOW() - c.last_reinforced_at) / 180.0) + ) AS fused_value, + COUNT(*) AS claim_count, + MAX(c.last_reinforced_at) AS last_reinforced_at + FROM claims c + JOIN source_trust st ON st.key = c.source + GROUP BY c.subject_type, c.subject_id, c.predicate, c.object_type, c.object_id, c.user_id; + + -- Compatibility view: track → artist credits via fusion + CREATE OR REPLACE VIEW track_artists_v2 AS + SELECT t.id AS track_id, + a.id AS artist_id, + a.name AS artist_name, + CASE cf.predicate WHEN 'credited_main_on' THEN 'main' ELSE 'featured' END AS role, + cf.fused_value AS confidence + FROM tracks t + JOIN claim_fusion cf + ON cf.subject_type = 'track' AND cf.subject_id = t.id + AND cf.predicate IN ('credited_main_on', 'featured_on') + AND cf.object_type = 'artist' + JOIN artists a ON a.id = cf.object_id; + + -- Compatibility view: album → artist credits via fusion + CREATE OR REPLACE VIEW album_artists_v2 AS + SELECT al.id AS album_id, + a.id AS artist_id, + a.name AS artist_name, + CASE cf.predicate WHEN 'credited_main_on_album' THEN 'main' ELSE 'featured' END AS role, + cf.fused_value AS confidence + FROM albums al + JOIN claim_fusion cf + ON cf.subject_type = 'album' AND cf.subject_id = al.id + AND cf.predicate IN ('credited_main_on_album', 'featured_on_album') + AND cf.object_type = 'artist' + JOIN artists a ON a.id = cf.object_id; + + `, + }, + { + // Backfill existing data into the claims graph: + // 1. track_artists → credited_main_on / featured_on claims (source=tag) + // 2. artist_similar → same_scene_as claims (source=lastfm, confidence=match) + // This makes the graph immediately usable without waiting for re-enrichment. + id: '20260707_backfill_claims', + sql: ` + -- 1. Populate claims from track_artists (tag-derived) + INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence, evidence_at) + SELECT + 'track' AS subject_type, + ta.track_id AS subject_id, + CASE WHEN ta.role = 'main' THEN 'credited_main_on' ELSE 'featured_on' END AS predicate, + 'artist' AS object_type, + ta.artist_id AS object_id, + 'tag' AS source, + 1.0 AS confidence, + NOW() AS evidence_at + FROM track_artists ta + ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) DO NOTHING; + + -- 2. Populate claims from artist_similar (Last.fm-derived) + INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence, evidence_at) + SELECT + 'artist' AS subject_type, + ar.id AS subject_id, + 'same_scene_as' AS predicate, + 'artist' AS object_type, + similar_ar.id AS object_id, + 'lastfm' AS source, + LEAST(asim.match, 1.0) AS confidence, + COALESCE(asim.fetched_at, NOW()) AS evidence_at + FROM artist_similar asim + JOIN artists ar ON ar.id = asim.artist_id + -- Resolve similar_name to an artist row so object_id is a real entity + JOIN artists similar_ar ON similar_ar.normalized_name = normalize_artist(asim.similar_name) + ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) DO NOTHING; + + -- 3. Also write claims with source='lastfm' for similar_name that didn't + -- resolve to an artist row (store as 'artist' object_type with name in raw) + INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence, evidence_at, raw) + SELECT + 'artist' AS subject_type, + ar.id AS subject_id, + 'same_scene_as' AS predicate, + 'artist_name' AS object_type, + gen_random_uuid() AS object_id, + 'lastfm' AS source, + LEAST(asim.match, 1.0) AS confidence, + COALESCE(asim.fetched_at, NOW()) AS evidence_at, + jsonb_build_object('similar_name', asim.similar_name) + FROM artist_similar asim + JOIN artists ar ON ar.id = asim.artist_id + WHERE NOT EXISTS ( + SELECT 1 FROM artists a WHERE a.normalized_name = normalize_artist(asim.similar_name) + ) + ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) DO NOTHING; + `, + }, + { + id: '20260708_materialize_claim_fusion', + sql: ` + -- Drop old view + dependent views + DROP VIEW IF EXISTS claim_fusion CASCADE; + DROP VIEW IF EXISTS track_artists_v2 CASCADE; + DROP VIEW IF EXISTS album_artists_v2 CASCADE; + + -- Create materialized view (same query as old view) + CREATE MATERIALIZED VIEW IF NOT EXISTS claim_fusion AS + SELECT + c.subject_type, + c.subject_id, + c.predicate, + c.object_type, + c.object_id, + COALESCE(c.user_id, '00000000-0000-0000-0000-000000000000'::uuid) AS user_id, + SUM( + st.trust * c.confidence * + GREATEST(0.1, 1.0 - EXTRACT(DAY FROM NOW() - c.last_reinforced_at) / 180.0) + ) AS fused_value, + COUNT(*) AS claim_count, + MAX(c.last_reinforced_at) AS last_reinforced_at + FROM claims c + JOIN source_trust st ON st.key = c.source + GROUP BY c.subject_type, c.subject_id, c.predicate, c.object_type, c.object_id, c.user_id; + + -- Unique index on the MV + CREATE UNIQUE INDEX IF NOT EXISTS idx_claim_fusion_pk ON claim_fusion (subject_type, subject_id, predicate, object_type, object_id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000')); + + -- Recreate compatibility views (now reading from MV) + CREATE OR REPLACE VIEW track_artists_v2 AS + SELECT t.id AS track_id, + a.id AS artist_id, + a.name AS artist_name, + CASE cf.predicate WHEN 'credited_main_on' THEN 'main' ELSE 'featured' END AS role, + cf.fused_value AS confidence + FROM tracks t + JOIN claim_fusion cf + ON cf.subject_type = 'track' AND cf.subject_id = t.id + AND cf.predicate IN ('credited_main_on', 'featured_on') + AND cf.object_type = 'artist' + JOIN artists a ON a.id = cf.object_id; + + CREATE OR REPLACE VIEW album_artists_v2 AS + SELECT al.id AS album_id, + a.id AS artist_id, + a.name AS artist_name, + CASE cf.predicate WHEN 'credited_main_on_album' THEN 'main' ELSE 'featured' END AS role, + cf.fused_value AS confidence + FROM albums al + JOIN claim_fusion cf + ON cf.subject_type = 'album' AND cf.subject_id = al.id + AND cf.predicate IN ('credited_main_on_album', 'featured_on_album') + AND cf.object_type = 'artist' + JOIN artists a ON a.id = cf.object_id; + + -- Refresh function for the MV + CREATE OR REPLACE FUNCTION refresh_claim_fusion() RETURNS void AS $$ + BEGIN + REFRESH MATERIALIZED VIEW CONCURRENTLY claim_fusion; + END; + $$ LANGUAGE plpgsql; + + -- Trigger function that notifies on claims changes + CREATE OR REPLACE FUNCTION notify_claim_fusion_change() RETURNS trigger AS $$ + BEGIN + NOTIFY claim_fusion_changed; + RETURN NULL; + END; + $$ LANGUAGE plpgsql; + + -- Trigger on claims table + DROP TRIGGER IF EXISTS trg_claim_fusion_refresh ON claims; + CREATE TRIGGER trg_claim_fusion_refresh AFTER INSERT OR UPDATE OR DELETE ON claims FOR EACH STATEMENT EXECUTE FUNCTION notify_claim_fusion_change(); + `, + }, + { + id: '20260708_fix_claim_fusion_index', + sql: ` + -- Drop the expression-based unique index that blocks CONCURRENTLY refresh. + -- The MV's user_id column is already COALESCE'd (non-null) from the SELECT, + -- so we can use the plain column name instead. + DROP INDEX IF EXISTS idx_claim_fusion_pk; + CREATE UNIQUE INDEX idx_claim_fusion_pk + ON claim_fusion (subject_type, subject_id, predicate, object_type, object_id, user_id); + `, + }, + { + id: '20260709_artists_mbid_unique', + sql: ` + -- Replace the non-unique partial index on artists.mbid with a unique one, + -- so ON CONFLICT (mbid) works in MbSpineWriter.resolveArtist(). The partial + -- predicate (WHERE mbid IS NOT NULL) allows multiple artists with no MBID. + DROP INDEX IF EXISTS idx_artists_mbid; + CREATE UNIQUE INDEX IF NOT EXISTS artists_mbid_unique + ON artists (mbid) WHERE mbid IS NOT NULL; + `, + }, +]; + +export class DbService { + /** Exposed so route handlers (e.g. settings) can query the database directly. */ + readonly pgClient: PgClient; + + constructor( + pgClient: PgClient, + private searchService?: SearchService + ) { + this.pgClient = pgClient; + } + + /** + * Apply the canonical schema (backend/src/db/schema.sql) on boot. The file is + * fully idempotent — enums are guarded with DO/EXCEPTION blocks and every + * table/index uses IF NOT EXISTS — so running it on every startup is safe and + * self-provisions tables on databases whose volume predates a schema change + * (the docker-entrypoint-initdb.d mount only runs on FIRST init). This is what + * keeps "relation \"play_history\"/\"feedback\" does not exist" from recurring. + */ + async ensureSchema(): Promise<void> { + // Resolve relative to this module. At runtime this is dist/services/, and + // the SQL ships unbuilt at src/db/schema.sql (Dockerfile `COPY . .`), so go + // up two levels from dist/services -> app root, then into src/db. + const here = dirname(fileURLToPath(import.meta.url)); + const schemaPath = join(here, '..', '..', 'src', 'db', 'schema.sql'); + const sql = await readFile(schemaPath, 'utf8'); + await this.pgClient.query(sql); + } + + /** + * Run pending schema migrations on boot. + * + * Each migration is a { id, sql } object. `id` must be a stable, unique string + * (convention: "YYYYMMDD_short_description"). Once applied, the id is recorded + * in `schema_migrations` and never re-run — even if the SQL changes. + * + * To add a new migration: append to the MIGRATIONS array below. Never edit or + * remove an existing entry — that would leave the migration "applied" in the DB + * but with different SQL in code, which is a lie. Instead, add a new migration. + */ + async runMigrations(): Promise<void> { + await this.pgClient.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id TEXT PRIMARY KEY, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + + const applied = await this.pgClient.query('SELECT id FROM schema_migrations'); + const appliedIds = new Set(applied.rows.map((r: any) => r.id as string)); + + for (const migration of MIGRATIONS) { + if (appliedIds.has(migration.id)) continue; + console.log(`[DB] Running migration: ${migration.id}`); + await this.pgClient.query('BEGIN'); + try { + await this.pgClient.query(migration.sql); + await this.pgClient.query( + 'INSERT INTO schema_migrations (id) VALUES ($1)', + [migration.id] + ); + await this.pgClient.query('COMMIT'); + console.log(`[DB] Migration applied: ${migration.id}`); + } catch (err) { + await this.pgClient.query('ROLLBACK'); + console.error(`[DB] Migration failed: ${migration.id}`, err); + throw err; + } + } + } + + async getTracks(params: { limit?: number; offset?: number; sort_by?: string; order?: 'ASC' | 'DESC'; search?: string } = {}) { + const { limit = 100, offset = 0, sort_by = 'title', order = 'ASC', search } = params; + const validSortBy = ['title', 'artist', 'album_id', 'duration', 'play_count']; + const sortColumn = validSortBy.includes(sort_by) ? `"${sort_by}"` : '"title"'; + const sortOrder = order === 'DESC' ? 'DESC' : 'ASC'; + + if (search && this.searchService) { + const searchResults = await this.searchService.search('tracks', search, { + query_by: 'title,artist' + }); + return (searchResults?.hits || []).map((h: any) => h.document) as Track[]; + } + + // Exclude HIDDEN (disliked) and DELETED tracks from library views. + // Join albums to get artwork_id for track cover display. + let query = ` + SELECT t.*, al.artwork_id + FROM tracks t + LEFT JOIN albums al ON al.id = t.album_id + WHERE t.state NOT IN ('HIDDEN', 'DELETED') + `; + const queryParams: any[] = [limit, offset]; + let paramIndex = 3; + + if (search) { + query += ` AND (t.title ILIKE $${paramIndex} OR t.artist ILIKE $${paramIndex})`; + queryParams.push(`%${search}%`); + } + + query += ` ORDER BY ${sortColumn} ${sortOrder} LIMIT $1 OFFSET $2`; + + const res = await this.pgClient.query(query, queryParams); + return this.attachArtists(res.rows as Track[]); + } + + /** Attach artists array (from track_artists join) to a list of tracks. */ + private async attachArtists(tracks: Track[]): Promise<Track[]> { + if (tracks.length === 0) return tracks; + const ids = tracks.map((t) => t.id); + const artistsRes = await this.pgClient.query( + `SELECT ta.track_id, ta.role, a.id, a.name + FROM track_artists ta + JOIN artists a ON a.id = ta.artist_id + WHERE ta.track_id = ANY($1) + ORDER BY ta.role DESC`, + [ids] + ); + const byTrack = new Map<string, TrackArtist[]>(); + for (const row of artistsRes.rows) { + if (!byTrack.has(row.track_id)) byTrack.set(row.track_id, []); + byTrack.get(row.track_id)!.push({ id: row.id, name: row.name, role: row.role }); + } + return tracks.map((t) => ({ ...t, artists: byTrack.get(t.id) ?? [] })); + } + + // Search returning a Typesense-compatible shape ({ found, hits:[{document}] }). + // Prefers Typesense when its 'tracks' collection is populated; falls back to a + // Postgres ILIKE scan when Typesense isn't indexed/reachable yet (no indexing + // pipeline exists today — see TODO: build a tracks reindex into Typesense). + async searchTracks(q: string, limit = 50): Promise<{ found: number; hits: { document: Track }[] }> { + if (this.searchService) { + try { + const res: any = await this.searchService.search('tracks', q, { + query_by: 'title,artist', + per_page: limit, + }); + // Only trust Typesense when it actually returns hits. An empty result is + // ambiguous: it usually means the 'tracks' collection is unindexed (no + // indexing pipeline runs on scan), not that there are genuinely no + // matches — so fall through to the Postgres scan instead of returning []. + if (res && Array.isArray(res.hits) && res.hits.length > 0) { + return { + found: res.found ?? res.hits.length, + hits: res.hits.map((h: any) => ({ document: h.document as Track })), + }; + } + } catch { + // Typesense collection missing/unreachable — fall through to Postgres. + } + } + + const like = `%${q}%`; + const res = await this.pgClient.query( + `SELECT t.*, al.artwork_id FROM tracks t + LEFT JOIN albums al ON al.id = t.album_id + WHERE t.state = 'LIBRARY' AND (t.title ILIKE $1 OR t.artist ILIKE $1) + ORDER BY t.play_count DESC, t.title ASC + LIMIT $2`, + [like, limit] + ); + const rows = res.rows as Track[]; + return { found: rows.length, hits: rows.map((t) => ({ document: t })) }; + } + + async getArtists(params: { limit?: number; offset?: number } = {}): Promise<Artist[]> { + const { limit = 50, offset = 0 } = params; + const res = await this.pgClient.query( + 'SELECT * FROM artists ORDER BY name ASC LIMIT $1 OFFSET $2', + [limit, offset] + ); + return res.rows as Artist[]; + } + + async getArtistsById(id: string): Promise<ArtistWithAlbums | null> { + const artistRes = await this.pgClient.query('SELECT * FROM artists WHERE id = $1', [id]); + const artist = artistRes.rows[0] as Artist; + if (!artist) return null; + + const albumsRes = await this.pgClient.query('SELECT * FROM albums WHERE artist_id = $1', [id]); + const albums = albumsRes.rows as Album[]; + + return { + ...artist, + albums, + }; + } + + async getSimilarArtists(artistId: string): Promise<{ similar_name: string; match: number }[]> { + const res = await this.pgClient.query( + `SELECT similar_name, match + FROM artist_similar + WHERE artist_id = $1 + ORDER BY match DESC`, + [artistId] + ); + return res.rows; + } + + async getAlbums(params: { limit?: number; offset?: number } = {}): Promise<Album[]> { + const { limit = 50, offset = 0 } = params; + const res = await this.pgClient.query( + `SELECT al.*, ar.name AS artist_name + FROM albums al + LEFT JOIN artists ar ON ar.id = al.artist_id + ORDER BY al.title ASC + LIMIT $1 OFFSET $2`, + [limit, offset] + ); + return res.rows as Album[]; + } + + async getAlbumById(id: string): Promise<AlbumWithTracks | null> { + const albumRes = await this.pgClient.query('SELECT * FROM albums WHERE id = $1', [id]); + const album = albumRes.rows[0] as Album; + if (!album) return null; + + const tracksRes = await this.pgClient.query( + `SELECT t.*, al.artwork_id FROM tracks t + LEFT JOIN albums al ON al.id = t.album_id + WHERE t.album_id = $1 ORDER BY t.title ASC`, [id]); + const tracks = await this.attachArtists(tracksRes.rows as Track[]); + + return { + ...album, + tracks, + }; + } + + async getGenres(): Promise<Genre[]> { + const res = await this.pgClient.query( + `SELECT g.id, g.name, g.parent_id, COUNT(tg.track_id)::int AS track_count + FROM genre g + LEFT JOIN track_genre tg ON tg.genre_id = g.id + GROUP BY g.id + ORDER BY track_count DESC, g.name` + ); + return res.rows as Genre[]; + } + + async getGenreById(id: string): Promise<Genre | null> { + const res = await this.pgClient.query( + `SELECT g.id, g.name, g.parent_id, COUNT(tg.track_id)::int AS track_count + FROM genre g + LEFT JOIN track_genre tg ON tg.genre_id = g.id + WHERE g.id = $1 + GROUP BY g.id`, + [id] + ); + return (res.rows[0] as Genre) || null; + } + + async getTracksByGenre(genreId: string, limit = 100, offset = 0): Promise<Track[]> { + const res = await this.pgClient.query( + `SELECT t.id, t.path, t.hash, t.title, t.artist, t.album_id, t.duration, + t.state, t.play_count, t.skip_count, t.dislike_count, + t.last_played_at, t.mtime, t.source_type, al.artwork_id + FROM tracks t + JOIN track_genre tg ON tg.track_id = t.id + LEFT JOIN albums al ON al.id = t.album_id + WHERE tg.genre_id = $1 AND t.state = 'LIBRARY' + ORDER BY tg.weight DESC + LIMIT $2 OFFSET $3`, + [genreId, limit, offset] + ); + return res.rows as Track[]; + } + + async getFavorites(userId: string): Promise<Track[]> { + // Favorites and disliked tracks are mutually exclusive — a disliked track + // has state='HIDDEN' so it won't appear here. Defensive filter anyway. + const query = ` + SELECT t.*, al.artwork_id FROM tracks t + JOIN favorites f ON t.id = f.track_id + LEFT JOIN albums al ON al.id = t.album_id + WHERE f.user_id = $1 AND t.state = 'LIBRARY' + `; + const res = await this.pgClient.query(query, [userId]); + return res.rows as Track[]; + } + + async addFavorite(userId: string, trackId: string): Promise<void> { + await this.pgClient.query('INSERT INTO favorites (user_id, track_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [userId, trackId]); + } + + async removeFavorite(userId: string, trackId: string): Promise<void> { + await this.pgClient.query('DELETE FROM favorites WHERE user_id = $1 AND track_id = $2', [userId, trackId]); + } + + /** + * Dislike a track: atomically hides the track, inserts a dislike row, and + * logs a feedback event. Per the lifecycle spec, the track transitions from + * LIBRARY -> HIDDEN and is removed from all active views immediately. + */ + async dislikeTrack(userId: string, trackId: string): Promise<void> { + try { + await this.pgClient.query('BEGIN'); + + // Phase 1: hide the track in all active views + await this.pgClient.query( + "UPDATE tracks SET state = 'HIDDEN' WHERE id = $1 AND state = 'LIBRARY'", + [trackId] + ); + + // Phase 1: insert dislike row (idempotent — won't create duplicate) + await this.pgClient.query( + 'INSERT INTO dislikes (track_id) VALUES ($1) ON CONFLICT (track_id) DO NOTHING', + [trackId] + ); + + // Phase 1: log feedback signal for the Vibe learning loop + await this.pgClient.query( + "INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'disliked')", + [userId, trackId] + ); + + await this.pgClient.query('COMMIT'); + } catch (err) { + await this.pgClient.query('ROLLBACK'); + throw err; + } + + // Write evidence: hidden → negative profile (only on success) + await this.recordEvidence({ + user_id: userId, + entity_type: 'track', + entity_id: trackId, + signal: 'hidden', + profile: 'negative', + weight: -0.60, + }); + } + + /** + * Restore a disliked track: atomically removes the dislike row and sets the + * track back to LIBRARY. Per the lifecycle spec, this is the "User Recovery" + * reversal of the dislike action. + */ + async restoreDislike(trackId: string): Promise<void> { + try { + await this.pgClient.query('BEGIN'); + + await this.pgClient.query('DELETE FROM dislikes WHERE track_id = $1', [trackId]); + await this.pgClient.query( + "UPDATE tracks SET state = 'LIBRARY' WHERE id = $1", + [trackId] + ); + + await this.pgClient.query('COMMIT'); + } catch (err) { + await this.pgClient.query('ROLLBACK'); + throw err; + } + } + + /** + * Fetch all currently disliked tracks (in HIDDEN state via dislike rows). + * Returns DislikeEntry rows joined with track metadata. Used by the + * Quarantine/dislikes list endpoint. + */ + async getDislikedTracks(): Promise<DislikeEntry[]> { + const res = await this.pgClient.query( + `SELECT d.track_id, d.disliked_at, d.warned_at, d.deleted_at, + d.grace_hours, d.state, + t.title AS track_title, t.artist AS track_artist, t.path AS track_path + FROM dislikes d + JOIN tracks t ON t.id = d.track_id + ORDER BY d.disliked_at DESC` + ); + return res.rows as DislikeEntry[]; + } + + /** + * Fetch a single dislike row by track_id, or null if not disliked. + */ + async getDislikeByTrackId(trackId: string): Promise<DislikeEntry | null> { + const res = await this.pgClient.query( + `SELECT d.track_id, d.disliked_at, d.warned_at, d.deleted_at, + d.grace_hours, d.state, + t.title AS track_title, t.artist AS track_artist, t.path AS track_path + FROM dislikes d + JOIN tracks t ON t.id = d.track_id + WHERE d.track_id = $1`, + [trackId] + ); + return (res.rows[0] as DislikeEntry) || null; + } + + /** + * Permanently delete a track: remove from DB (cascades to related tables) + * and delete the physical file. Used by the cleanup_sweep worker after the + * full lifecycle (grace period + 24h warning) has elapsed. + */ + async hardDeleteTrack(userId: string, trackId: string, filePath: string): Promise<void> { + const { unlink } = await import('fs/promises'); + + // Log the permanent deletion feedback event first (before the track is gone) + await this.pgClient.query( + "INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'deleted_permanent')", + [userId, trackId] + ); + + // Write evidence: manual_deleted → negative profile (strongest negative + // signal, spec §B.3). entity_id has no FK to tracks, so the row survives. + await this.recordEvidence({ + user_id: userId, + entity_type: 'track', + entity_id: trackId, + signal: 'manual_deleted', + profile: 'negative', + weight: -0.90, + }); + + // Delete DB record (ON DELETE CASCADE handles track_genre, play_history, + // feedback, track_audio_features, track_lyrics, recommendation_batch_track) + await this.pgClient.query('DELETE FROM tracks WHERE id = $1', [trackId]); + + // Delete physical file from disk + try { + await unlink(filePath); + } catch { + // File may already be gone (e.g. missing). Log but don't abort. + } + } + + async recordPlay(userId: string, trackId: string, completed: boolean, batchId?: string): Promise<string> { + if (!completed) { + const res = await this.pgClient.query( + 'INSERT INTO play_history (user_id, track_id, batch_id, completed) VALUES ($1, $2, $3, $4) RETURNING id', + [userId, trackId, batchId ?? null, false] + ); + return res.rows[0].id as string; + } + + // Completed play: history insert + play_count bump + Success-Driven Center + // + evidence writing + listener-behavior claims. All atomic. + try { + await this.pgClient.query('BEGIN'); + + // 1. Record play history + const insertRes = await this.pgClient.query( + 'INSERT INTO play_history (user_id, track_id, batch_id, completed) VALUES ($1, $2, $3, $4) RETURNING id', + [userId, trackId, batchId ?? null, true] + ); + const historyId = insertRes.rows[0].id as string; + + // 2. Bump play count + last_played_at + await this.pgClient.query( + 'UPDATE tracks SET play_count = play_count + 1, last_played_at = NOW() WHERE id = $1', + [trackId] + ); + + // 3. Success-Driven Center: move the user's most-recent ACTIVE batch seed + await this.pgClient.query( + `UPDATE recommendation_batch + SET seed_track_id = $2, last_interaction_at = NOW() + WHERE id = ( + SELECT id FROM recommendation_batch + WHERE user_id = $1 AND status = 'ACTIVE' + ORDER BY last_interaction_at DESC + LIMIT 1 + )`, + [userId, trackId] + ); + + // 4. Write evidence: playback_completed → longterm affinity + await this.recordEvidence({ + user_id: userId, + entity_type: 'track', + entity_id: trackId, + signal: 'playback_completed', + profile: 'longterm', + weight: 0.10, + context: batchId ? { batch_id: batchId } : undefined, + }); + + // 5. Check for replay within 24h → strengthens longterm + obsession + const recentPlays = await this.pgClient.query( + `SELECT COUNT(*)::int AS cnt FROM play_history + WHERE user_id = $1 AND track_id = $2 AND completed = true + AND played_at > NOW() - INTERVAL '24 hours'`, + [userId, trackId] + ); + if ((recentPlays.rows[0]?.cnt as number) > 1) { + await this.recordEvidence({ + user_id: userId, + entity_type: 'track', + entity_id: trackId, + signal: 'replay_within_24h', + profile: 'longterm', + weight: 0.25, + }); + await this.recordEvidence({ + user_id: userId, + entity_type: 'track', + entity_id: trackId, + signal: 'replay_within_24h', + profile: 'obsession', + weight: 0.40, + }); + } + + // 6. Listener-behavior writer: back-to-back play within 30 min → weak edges + // Resolve artist IDs for current track and previous track, then write + // alias_of (same artist, different name) or same_scene_as (different artists). + const currentArtist = await this.pgClient.query( + `SELECT a.id AS artist_id, a.normalized_name + FROM track_artists ta + JOIN artists a ON a.id = ta.artist_id + WHERE ta.track_id = $1 AND ta.role = 'main' + LIMIT 1`, + [trackId] + ); + const currentArtistRow = currentArtist.rows[0] as { artist_id: string; normalized_name: string } | undefined; + + if (currentArtistRow) { + // Get the previous completed play's track + artist + const prevPlay = await this.pgClient.query( + `SELECT ph_prev.track_id AS prev_track_id + FROM play_history ph_this + JOIN play_history ph_prev ON ph_prev.user_id = ph_this.user_id AND ph_prev.completed = true + WHERE ph_this.track_id = $1 AND ph_this.user_id = $2 AND ph_this.completed = true + AND ph_prev.played_at < ph_this.played_at + ORDER BY ph_prev.played_at DESC + LIMIT 1`, + [trackId, userId] + ); + const prevTrackId = prevPlay.rows[0]?.prev_track_id as string | undefined; + + if (prevTrackId) { + // Check time gap + const times = await this.pgClient.query( + `SELECT EXTRACT(EPOCH FROM (ph_this.played_at - ph_prev.played_at)) / 60 AS min_gap + FROM play_history ph_this + JOIN play_history ph_prev ON ph_prev.id = ( + SELECT id FROM play_history + WHERE user_id = $1 AND track_id = $2 AND completed = true + ORDER BY played_at DESC LIMIT 1 + ) + WHERE ph_this.id = ( + SELECT id FROM play_history + WHERE user_id = $1 AND track_id = $3 AND completed = true + ORDER BY played_at DESC LIMIT 1 + )`, + [userId, prevTrackId, trackId] + ); + const gapMinutes = times.rows[0]?.min_gap as number | undefined; + + if (gapMinutes !== undefined && gapMinutes <= 30) { + const prevArtist = await this.pgClient.query( + `SELECT a.id AS artist_id, a.normalized_name + FROM track_artists ta + JOIN artists a ON a.id = ta.artist_id + WHERE ta.track_id = $1 AND ta.role = 'main' + LIMIT 1`, + [prevTrackId] + ); + const prevArtistRow = prevArtist.rows[0] as { artist_id: string; normalized_name: string } | undefined; + + if (prevArtistRow) { + if (prevArtistRow.normalized_name === currentArtistRow.normalized_name) { + // Same normalized artist name → weak alias_of + await this.upsertClaim({ + user_id: userId, + subject_type: 'artist', + subject_id: prevArtistRow.artist_id, + predicate: 'alias_of', + object_type: 'artist', + object_id: currentArtistRow.artist_id, + source: 'listener_behavior', + confidence: 0.2, + }); + } else { + // Different artists played back-to-back → weak same_scene_as + await this.upsertClaim({ + user_id: userId, + subject_type: 'artist', + subject_id: prevArtistRow.artist_id, + predicate: 'same_scene_as', + object_type: 'artist', + object_id: currentArtistRow.artist_id, + source: 'listener_behavior', + confidence: 0.3, + }); + } + } + } + } + } + + await this.pgClient.query('COMMIT'); + return historyId; + } catch (err) { + await this.pgClient.query('ROLLBACK'); + throw err; + } + } + + async recordSkip(userId: string, trackId: string): Promise<void> { + // Skips do NOT move the center (transient per spec). Also writes negative evidence. + try { + await this.pgClient.query('BEGIN'); + await this.pgClient.query('UPDATE tracks SET skip_count = skip_count + 1 WHERE id = $1', [trackId]); + await this.pgClient.query( + "INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'skipped')", + [userId, trackId] + ); + // Write evidence: skip_quick → negative profile + await this.recordEvidence({ + user_id: userId, + entity_type: 'track', + entity_id: trackId, + signal: 'skip_quick', + profile: 'negative', + weight: -0.20, + }); + await this.pgClient.query('COMMIT'); + } catch (err) { + await this.pgClient.query('ROLLBACK'); + throw err; + } + } + + async recordFeedback(userId: string, trackId: string, action: FeedbackAction): Promise<void> { + if (!FEEDBACK_ACTIONS.includes(action)) { + throw new Error(`Invalid feedback action: ${action}`); + } + await this.pgClient.query( + 'INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, $3)', + [userId, trackId, action] + ); + + // Also write evidence for promoted/disliked signals + if (action === 'promoted') { + await this.recordEvidence({ + user_id: userId, + entity_type: 'track', + entity_id: trackId, + signal: 'add_to_favorites', + profile: 'longterm', + weight: 0.60, + }); + } else if (action === 'disliked') { + await this.recordEvidence({ + user_id: userId, + entity_type: 'track', + entity_id: trackId, + signal: 'hidden', + profile: 'negative', + weight: -0.60, + }); + } + } + + async getHistory(userId: string, limit = 50): Promise<HistoryEntry[]> { + const res = await this.pgClient.query( + `SELECT t.*, al.artwork_id, ph.played_at AS played_at, ph.completed AS completed, ph.id AS history_id, ph.batch_id AS batch_id + FROM play_history ph + JOIN tracks t ON t.id = ph.track_id + LEFT JOIN albums al ON al.id = t.album_id + WHERE ph.user_id = $1 + ORDER BY ph.played_at DESC + LIMIT $2`, + [userId, limit] + ); + return res.rows as HistoryEntry[]; + } + + async createVibeSession(userId: string, seedTrackId: string): Promise<string> { + const res = await this.pgClient.query( + 'INSERT INTO recommendation_batch (user_id, seed_track_id, status) VALUES ($1, $2, \'ACTIVE\') RETURNING id', + [userId, seedTrackId] + ); + return res.rows[0].id; + } + + async getActiveVibeSession(userId: string): Promise<{ batchId: string, seedTrackId: string } | null> { + const res = await this.pgClient.query( + `SELECT b.id as "batchId", b.seed_track_id as "seedTrackId" + FROM recommendation_batch b + WHERE b.user_id = $1 AND b.status = 'ACTIVE' + ORDER BY b.last_interaction_at DESC LIMIT 1`, + [userId] + ); + return res.rows[0] || null; + } + + /** + * "Rolling Vibe" recommendation engine (full spec implementation). + * + * This is a DB-only, single-round-trip scorer: NO external API calls happen on + * the request path. All enrichment (artist_similar, track_genre, audio + * features) is populated out-of-band by the workers; here we only read it. + * + * ============================ SCORING MODEL ============================ + * Every LIBRARY candidate gets a weighted score: + * + * score = W_GENRE * genre_overlap (dominant signal) + * + W_ARTSIM * artist_similarity (Last.fm similar-artist match) + * + W_SAMEART * same_artist (mild "more of this artist") + * + W_FEEDBCK * feedback_affinity (per-user promoted/disliked genres) + * + W_AUDIO * audio_closeness (NULL-safe, 0 when data missing) + * + W_RANDOM * jitter (tie-break / exploration) + * + * - genre_overlap: SUM(track_genre.weight) over genres shared with the seed. + * Dominant because genre is the most reliable similarity signal we have. + * - artist_similarity: if the candidate's artist name is listed in + * artist_similar for the SEED's artist, add the stored Last.fm `match` + * (0..1). This is the "discovery within library" nudge. + * - same_artist: small flat bonus when candidate shares the seed's artist. + * - feedback_affinity: bounded per-user term. Genres the user has 'promoted' + * push a candidate up; genres they've 'disliked' push it down. Clamped to + * [-1, 1] so a noisy feedback history can never dominate genre matching. + * - audio_closeness: see audioClosenessSQL() — NULL-safe, contributes 0 when + * either side lacks features. Low weight until real Essentia data lands. + * - jitter: RANDOM() in [0,1), scaled small, purely for variety / tie-breaks. + * + * ===================== 80/20 LOCAL vs PROBATION ======================== + * The 20-track chunk is split into two pools UNIONed with a `source` tag: + * - LOCAL (~16): the full scoring model above. "More of what you know." + * - PROBATION (~4): discovery-leaning. Scores LIBRARY tracks by + * artist_similar.match + least-recently-played recency, with genre + * overlap down-weighted, so it feels exploratory. If the probation pool + * is empty (no similarity data yet) the local pool simply fills the full + * 20 (see the genre-cap fill step), so the chunk is never short. + * + * ===================== DIVERSITY CONSTRAINTS ========================== + * Two caps are enforced AFTER scoring, over a generously-sized candidate set: + * 1. Max 1 track per ARTIST: ROW_NUMBER() OVER (PARTITION BY artist ...) = 1. + * 2. Max 2 tracks per GENRE: we attribute each track a single "primary genre" + * (highest-weight track_genre row) and apply a running + * COUNT() <= 2 window over that primary genre, ordered by the merged + * pool priority. Tracks with no genre are never capped. We over-fetch + * (LIMIT 60) before the caps so the caps don't starve the final 20 when + * more diverse tracks are actually available. + * + * ============================ FALLBACK =============================== + * If the seed has no genres AND no artist-similar data, every structured term + * is 0 and ordering collapses to (same_artist + feedback + jitter) — i.e. a + * graceful, mostly-random ordering over LIBRARY tracks rather than empties. + * + * After selection we record the chunk into recommendation_batch_track + * (ON CONFLICT DO NOTHING) so subsequent chunks for this batch exclude them. + */ + async getNextVibeChunk(batchId: string): Promise<Track[]> { + // --- Named scoring weights (see scoring-model comment block above) --- + const W_GENRE = 1.0; // dominant: shared-genre weight sum + const W_ARTSIM = 0.8; // Last.fm similar-artist match (0..1) + const W_SAMEART = 0.4; // flat bonus for same artist as the seed + const W_FEEDBCK = 0.6; // per-user promoted/disliked genre affinity, clamped + const W_AUDIO = 0.4; // NULL-safe audio closeness (energy + bpm + danceability) + const W_RANDOM = 0.3; // jitter for tie-breaking / exploration + + // Probation (discovery) pool weights: lean on artist similarity + recency, + // de-emphasise direct genre overlap so it feels like exploration. + const P_ARTSIM = 1.0; // similar-artist match is the primary discovery signal + const P_GENRE = 0.25; // genre overlap matters less in discovery + const P_RECENCY = 0.5; // least-recently-played gets surfaced + const P_RANDOM = 0.4; + + const LOCAL_TARGET = 16; // ~80% of the 20-track chunk + const PROBATION_TARGET = 4; // ~20% of the 20-track chunk + const CHUNK_SIZE = 20; + const OVERFETCH = 60; // fetch extra so diversity caps don't starve the 20 + const GENRE_CAP = 2; // max tracks per primary genre per chunk + + const trackCols = ` + id, path, hash, title, artist, album_id, duration, state, + play_count, skip_count, dislike_count, last_played_at, mtime, source_type`; + + const query = ` + WITH seed AS ( + SELECT t.id, t.artist, t.normalized_artist + FROM recommendation_batch rb + JOIN tracks t ON t.id = rb.seed_track_id + WHERE rb.id = $1 + ), + seed_user AS ( + SELECT user_id FROM recommendation_batch WHERE id = $1 + ), + -- seed artist UUID (tracks.artist is a name; artist_similar keys on artists.id) + -- Join on normalized identity, NOT raw name, so "The Beatles" (track) still + -- matches "the beatles" / "Beatles" (artist row). A raw-name join silently + -- fails on any case/feature difference and zeroes the artist_sim signal. + seed_artist AS ( + SELECT a.id AS artist_id + FROM seed s + JOIN artists a ON a.normalized_name = s.normalized_artist + ), + seed_genres AS ( + SELECT tg.genre_id, tg.weight + FROM seed s + JOIN track_genre tg ON tg.track_id = s.id + ), + -- artists Last.fm-similar to the seed's artist (by name, for candidate join) + -- normalize_artist() on similar_name so the join to tracks.normalized_artist + -- matches case- and feature-insensitively. Handles both old rows (raw + -- Last.fm names) and new rows (already normalized by the worker). + similar_artists AS ( + SELECT normalize_artist(asim.similar_name) AS similar_name, asim.match + FROM seed_artist sa + JOIN artist_similar asim ON asim.artist_id = sa.artist_id + ), + -- per-user genre affinity from feedback: +promoted, -disliked, clamped [-1,1] + feedback_genre AS ( + SELECT tg.genre_id, + GREATEST(-1.0, LEAST(1.0, + SUM(CASE f.action + WHEN 'promoted' THEN 0.5 + WHEN 'disliked' THEN -0.5 + ELSE 0 END) + )) AS affinity + FROM feedback f + JOIN seed_user su ON su.user_id = f.user_id + JOIN track_genre tg ON tg.track_id = f.track_id + WHERE f.action IN ('promoted', 'disliked') + GROUP BY tg.genre_id + ), + -- primary genre per track = its highest-weight track_genre row (for genre cap) + primary_genre AS ( + SELECT track_id, genre_id FROM ( + SELECT tg.track_id, tg.genre_id, + ROW_NUMBER() OVER (PARTITION BY tg.track_id ORDER BY tg.weight DESC) AS rn + FROM track_genre tg + ) pg WHERE rn = 1 + ), + base AS ( + SELECT + t.*, + pg.genre_id AS primary_genre_id, + -- genre overlap: sum of shared-genre weights with the seed + COALESCE(( + SELECT SUM(tg.weight) + FROM track_genre tg + JOIN seed_genres sg ON sg.genre_id = tg.genre_id + WHERE tg.track_id = t.id + ), 0) AS genre_overlap, + -- best similar-artist match for this candidate's normalized artist (0 if none) + COALESCE(( + SELECT MAX(sa.match) FROM similar_artists sa + WHERE sa.similar_name = t.normalized_artist + ), 0) AS artist_sim, + (CASE WHEN t.normalized_artist = s.normalized_artist THEN 1 ELSE 0 END) AS same_artist, + -- bounded feedback affinity: sum candidate's genre affinities, clamp + GREATEST(-1.0, LEAST(1.0, COALESCE(( + SELECT SUM(fg.affinity) + FROM track_genre tg + JOIN feedback_genre fg ON fg.genre_id = tg.genre_id + WHERE tg.track_id = t.id + ), 0))) AS feedback_affinity, + -- NULL-safe audio closeness vs seed (0 when either side has no features) + ${this.audioClosenessSQL('t.id')} AS audio_closeness, + -- recency: oldest last_played_at scores highest (NULLs = never played = max) + COALESCE(EXTRACT(EPOCH FROM (NOW() - t.last_played_at)) / 2592000.0, 1.0) + AS recency, + -- session-level artist play count: how many times this normalized artist + -- has already been recommended in the current batch (for decay scoring). + -- Using normalized_artist so "Artist feat. Guest" and "Artist" share a count. + (SELECT COUNT(*) FROM recommendation_batch_track rbt + JOIN tracks tr ON tr.id = rbt.track_id + WHERE rbt.batch_id = $1 AND tr.normalized_artist = t.normalized_artist) AS artist_play_count + FROM tracks t + CROSS JOIN seed s + LEFT JOIN primary_genre pg ON pg.track_id = t.id + WHERE t.state = 'LIBRARY' + AND t.id != s.id + AND NOT EXISTS ( + SELECT 1 FROM recommendation_batch_track rbt + WHERE rbt.batch_id = $1 AND rbt.track_id = t.id + ) + ), + -- LOCAL pool: full scoring model + local_pool AS ( + SELECT b.*, + 'local'::text AS source, + ( ( ${W_GENRE} * b.genre_overlap + + ${W_ARTSIM} * b.artist_sim + + ${W_SAMEART} * b.same_artist + + ${W_FEEDBCK} * b.feedback_affinity + + ${W_AUDIO} * b.audio_closeness + + ${W_RANDOM} * RANDOM() ) + * GREATEST(0.2, 1.0 - (b.artist_play_count - 1) * 0.20) ) AS score + FROM base b + ), + -- PROBATION pool: discovery-leaning, only candidates with a similarity signal + probation_pool AS ( + SELECT b.*, + 'probation'::text AS source, + ( ( ${P_ARTSIM} * b.artist_sim + + ${P_GENRE} * b.genre_overlap + + ${P_RECENCY} * LEAST(b.recency, 2.0) + + ${P_RANDOM} * RANDOM() ) + * GREATEST(0.2, 1.0 - (b.artist_play_count - 1) * 0.20) ) AS score + FROM base b + WHERE b.artist_sim > 0 + ), + local_ranked AS ( + SELECT lp.*, ROW_NUMBER() OVER (ORDER BY lp.score DESC) AS rn + FROM local_pool lp + ), + probation_ranked AS ( + SELECT pp.*, ROW_NUMBER() OVER (ORDER BY pp.score DESC) AS rn + FROM probation_pool pp + ), + -- merge: take top probation candidates first, then top local, dedup by id + merged AS ( + SELECT * FROM ( + SELECT pr.*, 0 AS pool_order FROM probation_ranked pr WHERE pr.rn <= ${PROBATION_TARGET} + UNION ALL + SELECT lr.*, 1 AS pool_order FROM local_ranked lr WHERE lr.rn <= ${OVERFETCH} + ) u + ), + -- dedup (a track can appear in both pools): keep its best (probation-first) row + deduped AS ( + SELECT m.* FROM ( + SELECT *, ROW_NUMBER() OVER ( + PARTITION BY id ORDER BY pool_order ASC, score DESC + ) AS dedup_rn + FROM merged + ) m WHERE m.dedup_rn = 1 + ), + -- DIVERSITY CAP 1: max 1 per normalized artist (keep best-scoring row per + -- normalized artist). Uses normalized_artist so "Artist feat. Guest" and + -- "Artist" are treated as the same artist for dedup. + artist_capped AS ( + SELECT d.* FROM ( + SELECT *, ROW_NUMBER() OVER ( + PARTITION BY normalized_artist ORDER BY pool_order ASC, score DESC + ) AS artist_rn + FROM deduped + ) d WHERE d.artist_rn = 1 + ), + -- DIVERSITY CAP 2: max ${GENRE_CAP} per primary genre. NULL-genre tracks + -- are never capped (assigned rank 1). Order by pool/score so the best + -- representatives of each genre survive. + genre_capped AS ( + SELECT g.* FROM ( + SELECT *, + CASE WHEN primary_genre_id IS NULL THEN 1 + ELSE ROW_NUMBER() OVER ( + PARTITION BY primary_genre_id ORDER BY pool_order ASC, score DESC + ) END AS genre_rn + FROM artist_capped + ) g WHERE g.genre_rn <= ${GENRE_CAP} + ), + chosen AS ( + SELECT * FROM genre_capped + ORDER BY pool_order ASC, score DESC + LIMIT ${CHUNK_SIZE} + ), + recorded AS ( + INSERT INTO recommendation_batch_track (batch_id, track_id) + SELECT $1, id FROM chosen + ON CONFLICT DO NOTHING + ) + SELECT ${trackCols}, al.artwork_id + FROM chosen + LEFT JOIN albums al ON al.id = chosen.album_id + ORDER BY score DESC; + `; + + void LOCAL_TARGET; // documented split target; LOCAL fills remainder via OVERFETCH + const res = await this.pgClient.query(query, [batchId]); + return res.rows as Track[]; + } + + /** + * NULL-safe audio-feature closeness term. + * + * Returns a SQL scalar expression (0..1, higher = more similar) comparing the + * candidate track (`candidateIdExpr`) against the seed's audio features via a + * LEFT JOIN-style correlated lookup. It is COALESCE/NULL-safe: if EITHER the + * candidate OR the seed lacks a track_audio_features row (or the compared + * columns are NULL), the term evaluates to 0 so missing audio data never zeroes + * a track out of the running — it simply doesn't contribute. + * + * Closeness = average normalized closeness across energy, bpm and danceability. + * energy/danceability are 0..1; bpm is normalised over a 200 BPM span. + * Each dimension is optional: only dimensions where BOTH seed and candidate + * have a non-NULL value contribute, and the divisor shrinks accordingly so a + * track missing one feature isn't unfairly penalised. + */ + private audioClosenessSQL(candidateIdExpr: string): string { + return ` + COALESCE(( + SELECT + ( CASE WHEN sf.energy IS NOT NULL AND cf.energy IS NOT NULL + THEN (1 - LEAST(ABS(cf.energy - sf.energy), 1)) ELSE NULL END + + CASE WHEN sf.bpm IS NOT NULL AND cf.bpm IS NOT NULL + THEN (1 - LEAST(ABS(cf.bpm - sf.bpm) / 200.0, 1)) ELSE NULL END + + CASE WHEN sf.danceability IS NOT NULL AND cf.danceability IS NOT NULL + THEN (1 - LEAST(ABS(cf.danceability - sf.danceability), 1)) ELSE NULL END + ) / NULLIF( + (CASE WHEN sf.energy IS NOT NULL AND cf.energy IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN sf.bpm IS NOT NULL AND cf.bpm IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN sf.danceability IS NOT NULL AND cf.danceability IS NOT NULL THEN 1 ELSE 0 END + ), 0) + FROM track_audio_features cf + JOIN track_audio_features sf ON sf.track_id = (SELECT id FROM seed) + WHERE cf.track_id = ${candidateIdExpr} + ), 0)`; + } + + /** + * GET /api/vibe/from-genre — start a chunk seeded by a genre rather than a track. + * + * Accepts a genre id (UUID) or a genre name. Scores LIBRARY tracks by their + * membership weight in that genre (+ jitter), applies the same diversity caps + * (max 1 per artist, max 2 per primary genre) and returns up to 20 Track[]. + * + * Design decision: this does NOT create a recommendation_batch and does NOT + * require an active session. It is a lightweight, stateless "play this genre" + * entry point; the caller can subsequently POST /start to roll a real session. + * Because there's no batch, returned tracks are not recorded anywhere. + */ + async getVibeChunkFromGenre(genreIdOrName: string, _userId: string): Promise<Track[]> { + const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + genreIdOrName + ); + const GENRE_CAP = 2; + // Note: no table prefix — these cols flow through CTEs (genre_capped) + // where the `t.` / `al.` aliases no longer apply. + const trackCols = ` + id, path, hash, title, artist, album_id, duration, state, + play_count, skip_count, dislike_count, last_played_at, mtime, source_type, artwork_id`; + + const query = ` + WITH target_genre AS ( + SELECT id FROM genre WHERE ${isUuid ? 'id = $1::uuid' : 'name = $1'} + ), + base AS ( + SELECT t.*, al.artwork_id, + tg.weight AS genre_weight, + pg.genre_id AS primary_genre_id, + (tg.weight * 1.0 + RANDOM() * 0.3) AS score + FROM tracks t + JOIN track_genre tg ON tg.track_id = t.id + JOIN target_genre g ON g.id = tg.genre_id + LEFT JOIN albums al ON al.id = t.album_id + LEFT JOIN ( + SELECT track_id, genre_id FROM ( + SELECT tg2.track_id, tg2.genre_id, + ROW_NUMBER() OVER (PARTITION BY tg2.track_id ORDER BY tg2.weight DESC) AS rn + FROM track_genre tg2 + ) p WHERE rn = 1 + ) pg ON pg.track_id = t.id + WHERE t.state = 'LIBRARY' + ), + artist_capped AS ( + SELECT b.* FROM ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY normalize_artist(artist) ORDER BY score DESC) AS artist_rn + FROM base + ) b WHERE b.artist_rn = 1 + ), + genre_capped AS ( + SELECT g.* FROM ( + SELECT *, + CASE WHEN primary_genre_id IS NULL THEN 1 + ELSE ROW_NUMBER() OVER (PARTITION BY primary_genre_id ORDER BY score DESC) END + AS genre_rn + FROM artist_capped + ) g WHERE g.genre_rn <= ${GENRE_CAP} + ) + SELECT ${trackCols} FROM genre_capped + ORDER BY score DESC + LIMIT 20; + `; + const res = await this.pgClient.query(query, [genreIdOrName]); + return res.rows as Track[]; + } + + /** + * GET /api/vibe/current — active batch metadata for a user, or null. + */ + async getCurrentVibeSession(userId: string): Promise< + { batchId: string; seedTrackId: string | null; lastInteractionAt: Date } | null + > { + const res = await this.pgClient.query( + `SELECT id AS "batchId", seed_track_id AS "seedTrackId", + last_interaction_at AS "lastInteractionAt" + FROM recommendation_batch + WHERE user_id = $1 AND status = 'ACTIVE' + ORDER BY last_interaction_at DESC + LIMIT 1`, + [userId] + ); + return res.rows[0] || null; + } + + /** + * POST /api/vibe/heartbeat — bump last_interaction_at on the user's ACTIVE batch. + * Returns true if a session was found and updated. + */ + async heartbeatVibeSession(userId: string): Promise<boolean> { + const res = await this.pgClient.query( + `UPDATE recommendation_batch + SET last_interaction_at = CURRENT_TIMESTAMP + WHERE id = ( + SELECT id FROM recommendation_batch + WHERE user_id = $1 AND status = 'ACTIVE' + ORDER BY last_interaction_at DESC + LIMIT 1 + )`, + [userId] + ); + return (res.rowCount ?? 0) > 0; + } + + async updateVibeSession(batchId: string): Promise<void> { + await this.pgClient.query( + 'UPDATE recommendation_batch SET last_interaction_at = CURRENT_TIMESTAMP WHERE id = $1', + [batchId] + ); + } + + async endVibeSession(batchId: string): Promise<void> { + await this.pgClient.query( + "UPDATE recommendation_batch SET status = 'RESOLVED' WHERE id = $1", + [batchId] + ); + } + + /** + * Reap stale ACTIVE vibe sessions — Invariant B ("No Deadlocks"): every + * ACTIVE batch must eventually reach a terminal state. Sessions with no + * interaction for `staleHours` (default 24, per spec §4) are transitioned to + * RESOLVED so a returning user starts a fresh session instead of resuming a + * frozen one. Returns the number of sessions reaped. + */ + async reapStaleVibeSessions(staleHours = 24): Promise<number> { + const res = await this.pgClient.query( + `UPDATE recommendation_batch + SET status = 'RESOLVED' + WHERE status = 'ACTIVE' + AND last_interaction_at < NOW() - ($1 || ' hours')::INTERVAL`, + [String(staleHours)] + ); + return res.rowCount ?? 0; + } + + async createArtist(data: Artist): Promise<Artist> { + const res = await this.pgClient.query( + `INSERT INTO artists (name, mbid, discogs_id, image_path) + VALUES (normalize_artist($1), $2, $3, $4) RETURNING *`, + [data.name, data.mbid, data.discogs_id, data.image_path] + ); + return res.rows[0]; + } + + async updateArtist(id: string, data: Partial<Artist>): Promise<Artist> { + const fields = Object.keys(data).filter(k => data[k as keyof Artist] !== undefined); + if (fields.length === 0) throw new Error('No fields to update'); + + // Normalize name if it's being updated (the normalized_name generated column + // handles it automatically, but we want the stored name itself to match). + if (data.name !== undefined) { + const norm = await this.pgClient.query('SELECT normalize_artist($1) AS n', [data.name]); + data.name = norm.rows[0].n; + } + + const setClause = fields.map((f, i) => `"${f}" = $${i + 2}`).join(', '); + const values = fields.map(f => data[f as keyof Artist]); + + const res = await this.pgClient.query( + `UPDATE artists SET ${setClause} WHERE id = $1 RETURNING *`, + [id, ...values] + ); + return res.rows[0]; + } + + async deleteArtist(id: string): Promise<void> { + await this.pgClient.query('DELETE FROM artists WHERE id = $1', [id]); + } + + async createAlbum(data: Album): Promise<Album> { + const res = await this.pgClient.query( + 'INSERT INTO albums (artist_id, title, year, artwork_id) VALUES ($1, $2, $3, $4) RETURNING *', + [data.artist_id, data.title, data.year, data.artwork_id] + ); + return res.rows[0]; + } + + async updateAlbum(id: string, data: Partial<Album>): Promise<Album> { + const fields = Object.keys(data).filter(k => data[k as keyof Album] !== undefined); + if (fields.length === 0) throw new Error('No fields to update'); + + const setClause = fields.map((f, i) => `"${f}" = $${i + 2}`).join(', '); + const values = fields.map(f => (data as any)[f]); + + const res = await this.pgClient.query( + `UPDATE albums SET ${setClause} WHERE id = $1 RETURNING *`, + [id, ...values] + ); + return res.rows[0]; + } + + async deleteAlbum(id: string): Promise<void> { + await this.pgClient.query('DELETE FROM albums WHERE id = $1', [id]); + } + + async getTrackById(id: string): Promise<Track | null> { + const res = await this.pgClient.query( + `SELECT t.*, al.artwork_id FROM tracks t + LEFT JOIN albums al ON al.id = t.album_id + WHERE t.id = $1`, [id]); + return (res.rows[0] as Track) || null; + } + + async getTrackLyrics(trackId: string): Promise<{ lyrics_text: string | null; synced_lyrics: unknown | null; provider: string | null } | null> { + const res = await this.pgClient.query( + 'SELECT lyrics_text, synced_lyrics, provider FROM track_lyrics WHERE track_id = $1', + [trackId] + ); + return res.rows[0] ?? null; + } + + async createTrack(data: Track): Promise<Track> { + const res = await this.pgClient.query( + `INSERT INTO tracks (path, hash, title, artist, album_id, duration, state, source_type) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, + [data.path, data.hash, data.title, data.artist, data.album_id, data.duration, data.state, data.source_type] + ); + return res.rows[0]; + } + + async updateTrack(id: string, data: Partial<Track>): Promise<Track> { + const fields = Object.keys(data).filter(k => data[k as keyof Track] !== undefined); + if (fields.length === 0) throw new Error('No fields to update'); + + const setClause = fields.map((f, i) => `"${f}" = $${i + 2}`).join(', '); + const values = fields.map(f => (data as any)[f]); + + const res = await this.pgClient.query( + `UPDATE tracks SET ${setClause} WHERE id = $1 RETURNING *`, + [id, ...values] + ); + return res.rows[0]; + } + + async deleteTrack(id: string): Promise<void> { + await this.pgClient.query('DELETE FROM tracks WHERE id = $1', [id]); + } + + /** + * Permanently delete a disliked track: remove the file from disk, then cascade- + * delete the DB record (which also removes the dislikes row via ON DELETE CASCADE). + * Logs a 'deleted_permanent' feedback event. Atomic: DB deletion only happens after + * the file is gone (or the file was already missing). + */ + async permanentlyDeleteTrack(userId: string, trackId: string, filePath: string): Promise<void> { + try { + await unlink(filePath); + } catch (err: any) { + if (err.code !== 'ENOENT') throw err; + } + + try { + await this.pgClient.query('BEGIN'); + await this.pgClient.query( + "INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'deleted_permanent')", + [userId, trackId] + ); + // CASCADE deletes dislikes, play_history, feedback, etc. + await this.pgClient.query('DELETE FROM tracks WHERE id = $1', [trackId]); + await this.pgClient.query('COMMIT'); + } catch (err) { + await this.pgClient.query('ROLLBACK'); + throw err; + } + } + + /** + * Return duplicate track groups, keyed by the given mode. + * + * `hash` (default) — groups of tracks with the same content hash (byte- + * identical duplicates). Excludes placeholder-hash rows that were written + * before proper hashing existed. + * + * `title-artist` — groups of tracks with the same normalised title AND + * normalised artist across different albums (catches the same song appearing + * in a compilation or reissue). Excludes same-hash groups since those are + * already caught by the hash mode. + * + * Each group returned as `{ key, tracks }` where `key` is the hash (mode + * `hash`) or `"title // artist"` (mode `title-artist`). + */ + async getDuplicateGroups( + mode: 'hash' | 'title-artist' = 'hash' + ): Promise<{ key: string; tracks: Track[] }[]> { + if (mode === 'title-artist') { + const res = await this.pgClient.query<Track & { normalized_title: string; normalized_artist: string }>( + `SELECT t.*, LOWER(TRIM(t.title)) AS normalized_title + FROM tracks t + JOIN ( + SELECT LOWER(TRIM(title)) AS nt, normalized_artist AS na + FROM tracks + WHERE state = 'LIBRARY' + AND hash IS NOT NULL AND hash <> '' AND hash <> 'placeholder-hash' + AND normalized_artist IS NOT NULL AND normalized_artist <> '' + GROUP BY nt, na + HAVING COUNT(*) > 1 + ) dup ON LOWER(TRIM(t.title)) = dup.nt AND t.normalized_artist = dup.na + WHERE t.state = 'LIBRARY' + AND t.hash IS NOT NULL AND t.hash <> '' AND t.hash <> 'placeholder-hash' + AND t.normalized_artist IS NOT NULL AND t.normalized_artist <> '' + ORDER BY dup.nt, dup.na, t.play_count DESC, t.last_played_at DESC NULLS LAST` + ); + + const groups = new Map<string, Track[]>(); + for (const row of res.rows) { + const key = `${row.normalized_title} // ${row.normalized_artist}`; + const list = groups.get(key) ?? []; + list.push(row); + groups.set(key, list); + } + + // Exclude groups where all tracks have the same hash (hash dedup already + // covers those). + const result: { key: string; tracks: Track[] }[] = []; + for (const [key, tracks] of groups) { + const uniqueHashes = new Set(tracks.map((t) => t.hash)); + if (uniqueHashes.size <= 1) continue; + result.push({ key, tracks }); + } + return result; + } + + // Default: hash-based dedup. + const res = await this.pgClient.query<Track & { hash: string }>( + `SELECT t.* + FROM tracks t + JOIN ( + SELECT hash FROM tracks + WHERE hash IS NOT NULL AND hash <> '' AND hash <> 'placeholder-hash' + GROUP BY hash HAVING COUNT(*) > 1 + ) dup ON dup.hash = t.hash + ORDER BY t.hash, t.play_count DESC, t.last_played_at DESC NULLS LAST` + ); + const groups = new Map<string, Track[]>(); + for (const row of res.rows) { + const list = groups.get(row.hash) ?? []; + list.push(row); + groups.set(row.hash, list); + } + return [...groups.entries()].map(([key, tracks]) => ({ key, tracks })); + } + + /** + * Keep `keepId`, re-parent its play history / feedback onto it, delete the + * remaining files from disk, then hard-delete the rest. All inside a + * transaction so a partial failure leaves no orphans. + * + * File deletion happens BEFORE the transaction (best-effort: we delete the + * file, then commit the DB changes; if the DB commit fails the file is + * already gone but the track stays orphaned in the DB — a subsequent + * integrity sweep will catch it and mark it MISSING). + */ + async mergeDuplicates(keepId: string, deleteIds: string[]): Promise<void> { + // 1. Fetch paths for the tracks being deleted (before they're gone). + const { rows: losers } = await this.pgClient.query<{ id: string; path: string }>( + `SELECT id, path FROM tracks WHERE id = ANY($1::uuid[])`, + [deleteIds] + ); + + // 2. Delete files from disk (best-effort — tolerate missing files). + for (const row of losers) { + try { + await unlink(row.path); + } catch (err: any) { + if (err.code !== 'ENOENT') throw err; + } + } + + // 3. DB transaction: re-parent history + delete rows. + await this.pgClient.query('BEGIN'); + try { + for (const id of deleteIds) { + await this.pgClient.query( + `UPDATE play_history SET track_id = $1 WHERE track_id = $2`, + [keepId, id] + ); + await this.pgClient.query( + `UPDATE feedback SET track_id = $1 WHERE track_id = $2`, + [keepId, id] + ); + await this.pgClient.query(`DELETE FROM tracks WHERE id = $1`, [id]); + } + await this.pgClient.query('COMMIT'); + } catch (err) { + await this.pgClient.query('ROLLBACK'); + throw err; + } + } + + /** + * Advance a dislike to WARNED state: set warned_at and update state. + * Called by the cleanup sweep worker after the grace period expires. + */ + async markDislikeWarned(trackId: string): Promise<void> { + await this.pgClient.query( + `UPDATE dislikes SET warned_at = NOW(), state = 'WARNED' WHERE track_id = $1`, + [trackId] + ); + } + + // ========================================================================= + // v2 — System A: Knowledge Graph (probabilistic fusion) + // ========================================================================= + + /** + * UPSERT a claim into the graph. Idempotent: same (subject, predicate, object, + * source, user_id) refreshes last_reinforced_at without duplicating. + */ + async upsertClaim(claim: { + user_id?: string | null; + subject_type: string; + subject_id: string; + predicate: string; + object_type: string; + object_id: string; + source: string; + confidence?: number; + raw?: unknown; + }): Promise<string> { + const res = await this.pgClient.query( + `INSERT INTO claims (user_id, subject_type, subject_id, predicate, object_type, object_id, source, confidence, raw) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) + DO UPDATE SET last_reinforced_at = NOW(), evidence_at = NOW(), confidence = $8, raw = COALESCE($9, claims.raw) + RETURNING id`, + [ + claim.user_id ?? null, + claim.subject_type, + claim.subject_id, + claim.predicate, + claim.object_type, + claim.object_id, + claim.source, + claim.confidence ?? 1.0, + claim.raw ? JSON.stringify(claim.raw) : null, + ] + ); + return res.rows[0].id as string; + } + + /** + * Batch UPSERT claims. Wraps multiple upsertClaim calls in a transaction. + */ + async upsertClaims(claims: Parameters<DbService['upsertClaim']>[0][]): Promise<string[]> { + const ids: string[] = []; + await this.pgClient.query('BEGIN'); + try { + for (const claim of claims) { + const id = await this.upsertClaim(claim); + ids.push(id); + } + await this.pgClient.query('COMMIT'); + return ids; + } catch (err) { + await this.pgClient.query('ROLLBACK'); + throw err; + } + } + + /** + * Get claims by subject (entity + predicate filter). + */ + async getClaimsBySubject( + subjectType: string, + subjectId: string, + predicate?: string, + userId?: string + ): Promise<Claim[]> { + let sql = `SELECT * FROM claims WHERE subject_type = $1 AND subject_id = $2`; + const params: unknown[] = [subjectType, subjectId]; + let idx = 3; + + if (predicate) { + sql += ` AND predicate = $${idx}`; + params.push(predicate); + idx++; + } + if (userId) { + sql += ` AND (user_id IS NULL OR user_id = $${idx})`; + params.push(userId); + } + + sql += ` ORDER BY last_reinforced_at DESC`; + const res = await this.pgClient.query(sql, params); + return res.rows as Claim[]; + } + + /** + * Get fused value for a (subject, predicate, object) triple, optionally + * scoped to a user (includes user-keyed claims). + */ + async getFusedValue( + subjectType: string, + subjectId: string, + predicate: string, + objectType: string, + objectId: string, + userId?: string + ): Promise<number> { + let sql = ` + SELECT COALESCE(SUM(st.trust * c.confidence * + GREATEST(0.1, 1.0 - EXTRACT(DAY FROM NOW() - c.last_reinforced_at) / 180.0)), 0) AS fused + FROM claims c + JOIN source_trust st ON st.key = c.source + WHERE c.subject_type = $1 AND c.subject_id = $2 + AND c.predicate = $3 + AND c.object_type = $4 AND c.object_id = $5 + `; + const params: unknown[] = [subjectType, subjectId, predicate, objectType, objectId]; + + if (userId) { + sql += ` AND (c.user_id IS NULL OR c.user_id = $6)`; + params.push(userId); + } else { + sql += ` AND c.user_id IS NULL`; + } + + const res = await this.pgClient.query(sql, params); + return (res.rows[0]?.fused as number) ?? 0; + } + + /** + * Get fused artist credits for a track, returning the same shape as the old + * attachArtists() for backward compatibility, but sourced from claim_fusion. + */ + async getFusedTrackArtists(trackId: string, userId?: string): Promise<TrackArtist[]> { + let sql = ` + SELECT a.id, a.name, + CASE cf.predicate WHEN 'credited_main_on' THEN 'main' ELSE 'featured' END AS role, + cf.fused_value AS confidence + FROM claim_fusion cf + JOIN artists a ON a.id = cf.object_id + WHERE cf.subject_type = 'track' AND cf.subject_id = $1 + AND cf.predicate IN ('credited_main_on', 'featured_on') + AND cf.object_type = 'artist' + `; + const params: unknown[] = [trackId]; + + if (userId) { + sql += ` AND cf.user_id IN ('00000000-0000-0000-0000-000000000000', $2) ORDER BY cf.fused_value DESC, cf.predicate`; + params.push(userId); + } else { + sql += ` AND cf.user_id = '00000000-0000-0000-0000-000000000000' ORDER BY cf.fused_value DESC, cf.predicate`; + } + + const res = await this.pgClient.query(sql, params); + return res.rows as TrackArtist[]; + } + + // ========================================================================= + // v2 — System B: Listener Model + // ========================================================================= + + /** + * Record evidence (append-only). Writes a signal into the evidence stream. + */ + async recordEvidence(evidence: { + user_id: string; + entity_type: string; + entity_id: string; + signal: string; + profile: string; + weight: number; + context?: unknown; + }): Promise<string> { + const res = await this.pgClient.query( + `INSERT INTO evidence (user_id, entity_type, entity_id, signal, profile, weight, context) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`, + [ + evidence.user_id, + evidence.entity_type, + evidence.entity_id, + evidence.signal, + evidence.profile, + evidence.weight, + evidence.context ? JSON.stringify(evidence.context) : null, + ] + ); + const id = res.rows[0].id as string; + + // Derive the belief dimension from the signal. Per spec §B.3, every + // signal feeds the 'affinity' dimension EXCEPT 'play_of_never_seen', + // which feeds 'novelty_tolerance'. Each new evidence row must also + // upsert the matching listener_belief (spec §B.4) — otherwise evidence + // accumulates but beliefs never materialise. + const dimension = evidence.signal === 'play_of_never_seen' ? 'novelty_tolerance' : 'affinity'; + await this.updateListenerBelief({ + user_id: evidence.user_id, + profile: evidence.profile, + entity_type: evidence.entity_type, + entity_id: evidence.entity_id, + dimension, + value_delta: evidence.weight, + confidence_delta: 0.05, + }); + + return id; + } + + /** + * Batch record evidence. All or nothing. + */ + async recordEvidenceBatch( + evidenceList: Parameters<DbService['recordEvidence']>[0][] + ): Promise<string[]> { + const ids: string[] = []; + await this.pgClient.query('BEGIN'); + try { + for (const ev of evidenceList) { + const id = await this.recordEvidence(ev); + ids.push(id); + } + await this.pgClient.query('COMMIT'); + return ids; + } catch (err) { + await this.pgClient.query('ROLLBACK'); + throw err; + } + } + + /** + * Get listener beliefs for a user, optionally filtered by profile/entity. + */ + async getListenerBeliefs(params: { + userId: string; + profile?: string; + entityType?: string; + entityId?: string; + dimension?: string; + limit?: number; + orderBy?: 'value' | 'last_reinforced_at'; + order?: 'ASC' | 'DESC'; + }): Promise<ListenerBelief[]> { + const { + userId, profile, entityType, entityId, dimension, + limit = 50, orderBy = 'value', order = 'DESC', + } = params; + + let sql = `SELECT * FROM listener_beliefs WHERE user_id = $1`; + const sqlParams: unknown[] = [userId]; + let idx = 2; + + if (profile) { sql += ` AND profile = $${idx}`; sqlParams.push(profile); idx++; } + if (entityType) { sql += ` AND entity_type = $${idx}`; sqlParams.push(entityType); idx++; } + if (entityId) { sql += ` AND entity_id = $${idx}`; sqlParams.push(entityId); idx++; } + if (dimension) { sql += ` AND dimension = $${idx}`; sqlParams.push(dimension); idx++; } + + const validOrderBy = ['value', 'last_reinforced_at', 'confidence', 'evidence_count']; + const sortCol = validOrderBy.includes(orderBy) ? orderBy : 'value'; + const sortOrder = order === 'ASC' ? 'ASC' : 'DESC'; + sql += ` ORDER BY ${sortCol} ${sortOrder} LIMIT $${idx}`; + sqlParams.push(limit); + + const res = await this.pgClient.query(sql, sqlParams); + return res.rows as ListenerBelief[]; + } + + /** + * UPSERT a listener belief. Updates value, confidence, and evidence_count. + * Implements the belief update formula from spec §B.4. + */ + async updateListenerBelief(params: { + user_id: string; + profile: string; + entity_type: string; + entity_id: string; + dimension: string; + value_delta: number; + confidence_delta?: number; + }): Promise<void> { + const { user_id, profile, entity_type, entity_id, dimension, value_delta, confidence_delta = 0.05 } = params; + + await this.pgClient.query( + `INSERT INTO listener_beliefs (user_id, profile, entity_type, entity_id, dimension, value, confidence, evidence_count, last_reinforced_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, 1, NOW()) + ON CONFLICT (user_id, profile, entity_type, entity_id, dimension) + DO UPDATE SET + value = GREATEST(-1.0, LEAST(1.0, listener_beliefs.value + $6 * (1.0 - listener_beliefs.confidence))), + confidence = GREATEST(0, LEAST(1.0, listener_beliefs.confidence + $7)), + evidence_count = listener_beliefs.evidence_count + 1, + last_reinforced_at = NOW()`, + [user_id, profile, entity_type, entity_id, dimension, value_delta, confidence_delta] + ); + } + + // ========================================================================= + // v2 — System D: Session support + // ========================================================================= + + /** + * Create a new session state row. + */ + async createSessionState(userId: string, context?: string, stateVector?: Record<string, unknown>): Promise<string> { + const res = await this.pgClient.query( + `INSERT INTO session_state (user_id, context, state_vector) VALUES ($1, $2, $3) RETURNING session_id`, + [userId, context ?? null, stateVector ? JSON.stringify(stateVector) : '{}'] + ); + return res.rows[0].session_id as string; + } + + /** + * Get the most recent session state for a user. + */ + async getLatestSessionState(userId: string): Promise<SessionState | null> { + const res = await this.pgClient.query( + `SELECT * FROM session_state WHERE user_id = $1 ORDER BY last_interaction DESC LIMIT 1`, + [userId] + ); + return (res.rows[0] as SessionState) || null; + } + + /** + * Upsert a diversity budget for a user. + */ + async upsertDiversityBudget(budget: { + user_id: string; + dimension: string; + budget_share: number; + horizon_min: number; + }): Promise<void> { + await this.pgClient.query( + `INSERT INTO diversity_budgets (user_id, dimension, budget_share, horizon_min) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, dimension, horizon_min) + DO UPDATE SET budget_share = $3`, + [budget.user_id, budget.dimension, budget.budget_share, budget.horizon_min] + ); + } + + /** + * Seed default diversity budgets for a new user. + */ + async seedDefaultDiversityBudgets(userId: string): Promise<void> { + const defaults: { dimension: string; share: number; horizon: number }[] = [ + { dimension: 'artist', share: 0.20, horizon: 30 }, + { dimension: 'genre', share: 0.40, horizon: 30 }, + { dimension: 'language', share: 0.60, horizon: 30 }, + { dimension: 'instrumental', share: 0.10, horizon: 30 }, + { dimension: 'new_artist', share: 0.15, horizon: 60 }, + { dimension: 'favorite', share: 0.25, horizon: 60 }, + ]; + + for (const d of defaults) { + await this.upsertDiversityBudget({ + user_id: userId, + dimension: d.dimension, + budget_share: d.share, + horizon_min: d.horizon, + }); + } + } + + /** + * Refresh the claim_fusion materialised view. Called on a periodic + * timer so the graph's read path stays current with new claims. + * CONCURRENTLY requires the unique index (idx_claim_fusion_pk), + * which the 20260708_materialize_claim_fusion migration creates. + */ + async refreshClaimFusion(): Promise<void> { + try { + await this.pgClient.query('SELECT refresh_claim_fusion()'); + } catch (err) { + // Non-fatal: the MV may not exist yet on first boot before + // migrations run. Log and move on; the next tick will retry. + console.error('[DB] refresh_claim_fusion failed:', err); + } + } + + /** + * Decay all listener beliefs whose last_decayed_at is older than 1 + * hour. Implements the decay formula from spec §B.4: + * value *= 0.5 ^ (elapsed / halflife) + * confidence *= 0.5 ^ (elapsed / halflife) + * Halflife is per-profile (longterm=365d, obsession=14d, discovery=30d, + * negative=180d, contextual=7d). The 'forgotten' profile is excluded + * — it is fully derived nightly by deriveForgottenProfile(), not + * decayed. + */ + async decayBeliefs(): Promise<number> { + const res = await this.pgClient.query(` + WITH halflives AS ( + SELECT profile, + CASE profile + WHEN 'longterm' THEN 365 * 86400 + WHEN 'obsession' THEN 14 * 86400 + WHEN 'discovery' THEN 30 * 86400 + WHEN 'negative' THEN 180 * 86400 + WHEN 'contextual' THEN 7 * 86400 + ELSE 30 * 86400 + END AS halflife_sec + ) + UPDATE listener_beliefs lb + SET value = GREATEST(-1.0, LEAST(1.0, lb.value * POWER(0.5, + EXTRACT(EPOCH FROM (NOW() - lb.last_decayed_at)) / h.halflife_sec))), + confidence = GREATEST(0, LEAST(1.0, lb.confidence * POWER(0.5, + EXTRACT(EPOCH FROM (NOW() - lb.last_decayed_at)) / h.halflife_sec))), + last_decayed_at = NOW() + FROM halflives h + WHERE lb.profile = h.profile + AND lb.profile <> 'forgotten' + AND lb.last_decayed_at < NOW() - INTERVAL '1 hour' + `); + return res.rowCount ?? 0; + } + + /** + * Derive the 'forgotten' profile nightly (spec §B.2): + * longterm affinity > 0.3 AND not reinforced in 90+ days. + * Wipes and repopulates — 'forgotten' is fully derived, not evidence-fed. + */ + async deriveForgottenProfile(): Promise<number> { + await this.pgClient.query( + `DELETE FROM listener_beliefs WHERE profile = 'forgotten'` + ); + const res = await this.pgClient.query(` + INSERT INTO listener_beliefs + (user_id, profile, entity_type, entity_id, dimension, value, + confidence, evidence_count, last_reinforced_at, last_decayed_at) + SELECT user_id, 'forgotten', entity_type, entity_id, dimension, + value, confidence, evidence_count, last_reinforced_at, NOW() + FROM listener_beliefs + WHERE profile = 'longterm' + AND dimension = 'affinity' + AND value > 0.3 + AND last_reinforced_at < NOW() - INTERVAL '90 days' + ON CONFLICT (user_id, profile, entity_type, entity_id, dimension) + DO UPDATE SET + value = EXCLUDED.value, + confidence = EXCLUDED.confidence, + evidence_count = EXCLUDED.evidence_count, + last_reinforced_at = EXCLUDED.last_reinforced_at + `); + return res.rowCount ?? 0; + } +} diff --git a/backend/src/services/discovery.service.ts b/backend/src/services/discovery.service.ts new file mode 100644 index 0000000..2e87009 --- /dev/null +++ b/backend/src/services/discovery.service.ts @@ -0,0 +1,297 @@ +import { DbService } from './db.service.js'; + +export interface DiscoveryCandidate { + id: string; + source: string; + externalId: string; + title: string | null; + artistCredit: unknown; + notes: unknown; + status: string; + relevance: number; + explanation: string; +} + +export class DiscoveryService { + constructor(private db: DbService) {} + + // --------------------------------------------------------------- + // E.1 — Graph exploration: walk the graph beyond the library + // --------------------------------------------------------------- + async walkGraphForDiscovery(userId: string): Promise<number> { + const beliefs = await this.db.getListenerBeliefs({ + userId, + profile: 'longterm', + entityType: 'artist', + dimension: 'affinity', + limit: 100, + orderBy: 'value', + order: 'DESC', + }); + + const highAffinity = beliefs.filter((b) => b.value > 0.3); + let newCount = 0; + + for (const belief of highAffinity) { + const candidates = await this.db.pgClient.query<{ candidate_artist_id: string }>( + `SELECT cf.object_id AS candidate_artist_id + FROM claim_fusion cf + WHERE cf.subject_id = $1::uuid + AND cf.predicate IN ('same_scene_as', 'featured_on') + AND cf.object_type = 'artist' + AND NOT EXISTS ( + SELECT 1 FROM tracks t + JOIN claim_fusion cf2 ON cf2.subject_id = t.id + WHERE cf2.object_id = cf.object_id + AND cf2.predicate = 'credited_main_on' + ) + LIMIT 20`, + [belief.entity_id] + ); + + for (const row of candidates.rows) { + const dcRes = await this.db.pgClient.query<{ id: string }>( + `INSERT INTO discovery_candidates (source, external_id, artist_credit, notes) + VALUES ($1, $2, $3, $4) + ON CONFLICT (source, external_id) DO NOTHING + RETURNING id`, + [ + 'graph_exploration', + row.candidate_artist_id, + JSON.stringify([{ artist_id: row.candidate_artist_id }]), + JSON.stringify({ + discovery_source: 'graph_exploration', + path: [ + { + entity_id: belief.entity_id, + predicate: 'affinity_source', + profile: 'longterm', + affinity: belief.value, + }, + { + entity_id: row.candidate_artist_id, + predicate: 'same_scene_as', + }, + ], + source_artist_belief_id: belief.entity_id, + }), + ] + ); + + if (dcRes.rows.length === 0) continue; + + const dcId = dcRes.rows[0].id; + const relevance = Math.min(belief.value, 0.8); + + await this.db.upsertClaim({ + subject_type: 'track', + subject_id: dcId, + predicate: 'discovery_candidate', + object_type: 'artist', + object_id: row.candidate_artist_id, + source: 'graph_exploration', + confidence: relevance, + raw: { + discovery_source: 'graph_exploration', + path: [ + { entity_id: belief.entity_id, relationship: 'affinity_source', belief_value: belief.value }, + { entity_id: row.candidate_artist_id, relationship: 'same_scene_as' }, + ], + }, + }); + + newCount++; + } + } + + return newCount; + } + + // --------------------------------------------------------------- + // E.3 — Evaluate discovery candidates for acquisition + // --------------------------------------------------------------- + async evalCandidates( + userId: string, + limit?: number + ): Promise<{ candidateId: string; shouldAcquire: boolean; reason: string }[]> { + const cap = limit ?? 20; + const results: { candidateId: string; shouldAcquire: boolean; reason: string }[] = []; + + const candidates = await this.db.pgClient.query( + `SELECT * FROM discovery_candidates + WHERE status = 'candidate' + ORDER BY first_seen_at ASC + LIMIT $1`, + [cap] + ); + + const backlogRes = await this.db.pgClient.query( + `SELECT COUNT(*)::int AS cnt FROM discovery_candidates WHERE status = 'acquiring'` + ); + let backlog = backlogRes.rows[0]?.cnt as number ?? 0; + + for (const row of candidates.rows) { + const claimRes = await this.db.pgClient.query<{ object_id: string; fused_value: number }>( + `SELECT object_id, fused_value + FROM claim_fusion + WHERE subject_type = 'track' AND subject_id = $1::uuid + AND predicate = 'discovery_candidate' + LIMIT 1`, + [row.id] + ); + + const relevance = claimRes.rows[0]?.fused_value ?? 0; + const candidateArtistId = claimRes.rows[0]?.object_id; + + const noveltyBeliefs = await this.db.getListenerBeliefs({ + userId, + profile: 'discovery', + entityType: 'artist', + entityId: candidateArtistId, + dimension: 'tolerance', + limit: 1, + }); + const tolerance = noveltyBeliefs.length > 0 ? noveltyBeliefs[0].value : 0.5; + + let artistCount = 0; + if (candidateArtistId) { + const acRes = await this.db.pgClient.query( + `SELECT COUNT(*)::int AS cnt + FROM discovery_candidates dc + JOIN claims c ON c.subject_id = dc.id + WHERE dc.status = 'acquiring' + AND c.predicate = 'discovery_candidate' + AND c.object_id = $1::uuid`, + [candidateArtistId] + ); + artistCount = acRes.rows[0]?.cnt as number ?? 0; + } + + const shouldAcquire = relevance > 0.3 && tolerance > 0.2 && backlog < 20 && artistCount < 3; + let reason: string; + + if (shouldAcquire) { + await this.db.pgClient.query( + `UPDATE discovery_candidates SET status = 'acquiring', last_eval_at = NOW() WHERE id = $1`, + [row.id] + ); + backlog++; + reason = 'meets criteria'; + } else { + if (relevance <= 0.3) reason = 'relevance too low'; + else if (tolerance <= 0.2) reason = 'novelty tolerance exceeded'; + else if (backlog >= 20) reason = 'backlog full'; + else if (artistCount >= 3) reason = 'artist diversity limit'; + else reason = 'unknown'; + + await this.db.pgClient.query( + `UPDATE discovery_candidates SET status = 'retired', last_eval_at = NOW() WHERE id = $1`, + [row.id] + ); + } + + results.push({ + candidateId: row.id, + shouldAcquire, + reason, + }); + } + + return results; + } + + // --------------------------------------------------------------- + // E.4 — Probation lifecycle + // --------------------------------------------------------------- + async evalProbation(trackId: string): Promise<'retained' | 'retired' | 'probation'> { + const completedRes = await this.db.pgClient.query( + `SELECT COUNT(*)::int AS cnt FROM evidence + WHERE entity_type = 'track' AND entity_id = $1 AND signal = 'playback_completed'`, + [trackId] + ); + const completedPlays = completedRes.rows[0]?.cnt as number ?? 0; + + const skipRes = await this.db.pgClient.query( + `SELECT COUNT(*)::int AS cnt FROM evidence + WHERE entity_type = 'track' AND entity_id = $1 AND signal = 'skip_quick'`, + [trackId] + ); + const skips = skipRes.rows[0]?.cnt as number ?? 0; + + const trackRes = await this.db.pgClient.query<{ probation_entered_at: Date | null }>( + `SELECT probation_entered_at FROM tracks WHERE id = $1`, + [trackId] + ); + const probTrack = trackRes.rows[0]; + + if (completedPlays >= 3) { + await this.db.pgClient.query( + `UPDATE tracks SET probation_status = 'retained' WHERE id = $1`, + [trackId] + ); + + const claimRes = await this.db.pgClient.query<{ source: string }>( + `SELECT source FROM claims + WHERE subject_type = 'track' AND subject_id = $1 AND predicate = 'discovery_candidate' + LIMIT 1`, + [trackId] + ); + if (claimRes.rows[0]) { + await this.db.pgClient.query( + `UPDATE source_trust SET trust = LEAST(1.0, trust + 0.05) WHERE key = $1`, + [claimRes.rows[0].source] + ); + } + + return 'retained'; + } + + const daysSinceProbation = probTrack?.probation_entered_at + ? (Date.now() - new Date(probTrack.probation_entered_at).getTime()) / (1000 * 86400) + : 0; + + if (completedPlays === 0 && skips >= 3 && daysSinceProbation > 7) { + await this.db.pgClient.query( + `UPDATE tracks SET probation_status = 'retired' WHERE id = $1`, + [trackId] + ); + return 'retired'; + } + + return 'probation'; + } + + async sweepProbation(): Promise<{ retained: number; retired: number }> { + const res = await this.db.pgClient.query( + `SELECT id FROM tracks WHERE probation_status = 'probation'` + ); + + let retained = 0; + let retired = 0; + + for (const row of res.rows) { + const result = await this.evalProbation(row.id as string); + if (result === 'retained') retained++; + else if (result === 'retired') retired++; + } + + return { retained, retired }; + } + + // --------------------------------------------------------------- + // E.5 — Meta-learning stub + // --------------------------------------------------------------- + async runMetaLearning(): Promise<void> { + const res = await this.db.pgClient.query( + `SELECT c.source, COUNT(*)::int AS cnt + FROM claims c + JOIN tracks t ON t.id = c.subject_id + WHERE c.predicate = 'discovery_candidate' + AND t.probation_status = 'retained' + GROUP BY c.source + ORDER BY cnt DESC` + ); + + console.log('[MetaLearning] Discovery source retention counts:', JSON.stringify(res.rows)); + } +} diff --git a/backend/src/services/generators.service.ts b/backend/src/services/generators.service.ts new file mode 100644 index 0000000..4f6f295 --- /dev/null +++ b/backend/src/services/generators.service.ts @@ -0,0 +1,500 @@ +import { DbService, ListenerBelief } from './db.service.js'; + +// --------------------------------------------------------------------------- +// System C — Candidate Generators +// Each generator returns candidates with graph-path explanations. +// No scoring — the session director handles ranking. +// --------------------------------------------------------------------------- + +export interface ClaimEdge { + subjectType: string; + subjectId: string; + predicate: string; + objectType: string; + objectId: string; + fusedValue: number; +} + +export interface Candidate { + trackId: string; + generatorId: string; + explanation: ClaimEdge[]; + relevance: number; +} + +export interface GeneratorContext { + userId: string; + seedTrackId: string | null; + seedArtistId: string | null; + beliefs: ListenerBelief[]; + recentExclusions: string[]; + toleranceMap: Record<string, number>; + state: { + energy: number; + lastArtistIds: string[]; + lastGenreIds: string[]; + context: string | null; + noveltyHunger: number; + sessionAgeMin: number; + }; +} + +export type Generator = (db: DbService, ctx: GeneratorContext) => Promise<Candidate[]>; + +const OBJECTIVE_USER = '00000000-0000-0000-0000-000000000000'; + +// --------------------------------------------------------------------------- +// 1. COMFORT — Top artists by longterm affinity > 0.5 +// --------------------------------------------------------------------------- +async function comfortGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> { + const topArtists = ctx.beliefs + .filter(b => b.profile === 'longterm' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.5) + .sort((a, b) => b.value - a.value) + .slice(0, 20); + + const candidates: Candidate[] = []; + + for (const belief of topArtists) { + const res = await db.pgClient.query( + `SELECT t.id + FROM tracks t + JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id + AND cf.predicate IN ('credited_main_on', 'featured_on') + AND cf.object_type = 'artist' AND cf.object_id = $1 + AND (cf.user_id = $2 OR cf.user_id = $3) + WHERE t.state = 'LIBRARY' + AND NOT (t.id = ANY($4::uuid[])) + ORDER BY cf.fused_value DESC + LIMIT 2`, + [belief.entity_id, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions] + ); + + for (const row of res.rows as { id: string }[]) { + candidates.push({ + trackId: row.id, + generatorId: 'comfort', + explanation: [{ + subjectType: 'artist', + subjectId: belief.entity_id, + predicate: 'credited_main_on', + objectType: 'track', + objectId: row.id, + fusedValue: belief.value, + }], + relevance: belief.value, + }); + } + } + + return candidates; +} + +// --------------------------------------------------------------------------- +// 2. ADJACENT — Walk graph from seed artist, exclude comfort pool +// --------------------------------------------------------------------------- +async function adjacentGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> { + if (!ctx.seedArtistId) return []; + + const comfortArtistIds = new Set( + ctx.beliefs + .filter(b => b.profile === 'longterm' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.5) + .map(b => b.entity_id) + ); + + // Walk: seedArtist -> (credited_main_on|featured_on) -> track -> (credited_main_on|featured_on) -> reachedArtist + // cf1 finds tracks where seed artist appears; cf2 finds OTHER artists on those same tracks + const reachedRes = await db.pgClient.query( + `SELECT DISTINCT cf2.object_id AS artist_id + FROM claim_fusion cf1 + JOIN claim_fusion cf2 ON cf2.subject_type = 'track' + AND cf2.subject_id = cf1.subject_id + AND cf2.predicate IN ('credited_main_on', 'featured_on') + AND cf2.object_type = 'artist' + AND cf2.object_id != $1 + AND (cf2.user_id = $2 OR cf2.user_id = $3) + WHERE cf1.subject_type = 'track' + AND cf1.predicate IN ('credited_main_on', 'featured_on') + AND cf1.object_type = 'artist' + AND cf1.object_id = $1 + AND (cf1.user_id = $2 OR cf1.user_id = $3) + LIMIT 30`, + [ctx.seedArtistId, OBJECTIVE_USER, ctx.userId] + ); + + const reachedArtistIds = (reachedRes.rows as { artist_id: string }[]) + .map(r => r.artist_id) + .filter(id => !comfortArtistIds.has(id)); + + if (reachedArtistIds.length === 0) return []; + + const trackRes = await db.pgClient.query( + `SELECT id FROM ( + SELECT DISTINCT t.id + FROM tracks t + JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id + AND cf.predicate IN ('credited_main_on', 'featured_on') + AND cf.object_type = 'artist' + AND cf.object_id = ANY($1::uuid[]) + AND (cf.user_id = $2 OR cf.user_id = $3) + WHERE t.state = 'LIBRARY' + AND NOT (t.id = ANY($4::uuid[])) + ) sub + ORDER BY RANDOM() + LIMIT 20`, + [reachedArtistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions] + ); + + return (trackRes.rows as { id: string }[]).map(row => ({ + trackId: row.id, + generatorId: 'adjacent', + explanation: [{ + subjectType: 'artist', + subjectId: ctx.seedArtistId!, + predicate: 'credited_main_on', + objectType: 'track', + objectId: row.id, + fusedValue: 0.6, + }], + relevance: 0.6, + })); +} + +// --------------------------------------------------------------------------- +// 3. DISCOVERY — Unfamiliar artists via graph edges from trusted artists +// --------------------------------------------------------------------------- +async function discoveryGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> { + const trustedIds = ctx.beliefs + .filter(b => b.profile === 'longterm' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.3) + .map(b => b.entity_id); + + if (trustedIds.length === 0) return []; + + const noveltyTolerance = ctx.toleranceMap.novelty_tolerance ?? 0.3; + const maxCandidates = Math.max(1, Math.floor(10 * noveltyTolerance)); + + const unfamiliarRes = await db.pgClient.query( + `SELECT DISTINCT cf.object_id AS artist_id + FROM claim_fusion cf + WHERE cf.subject_type = 'artist' + AND cf.subject_id = ANY($1::uuid[]) + AND cf.predicate IN ('same_scene_as', 'same_label_as', 'produced') + AND cf.object_type = 'artist' + AND NOT EXISTS ( + SELECT 1 FROM listener_beliefs lb + WHERE lb.user_id = $2 + AND lb.entity_type = 'artist' + AND lb.entity_id = cf.object_id + AND lb.profile IN ('longterm', 'obsession') + ) + LIMIT 30`, + [trustedIds, ctx.userId] + ); + + const unfamiliarArtistIds = (unfamiliarRes.rows as { artist_id: string }[]).map(r => r.artist_id); + if (unfamiliarArtistIds.length === 0) return []; + + const trackRes = await db.pgClient.query( + `SELECT id FROM ( + SELECT DISTINCT t.id + FROM tracks t + JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id + AND cf.predicate IN ('credited_main_on', 'featured_on') + AND cf.object_type = 'artist' + AND cf.object_id = ANY($1::uuid[]) + WHERE t.state = 'LIBRARY' + AND NOT (t.id = ANY($2::uuid[])) + ) sub + ORDER BY RANDOM() + LIMIT $3`, + [unfamiliarArtistIds, ctx.recentExclusions, maxCandidates] + ); + + return (trackRes.rows as { id: string }[]).map(row => ({ + trackId: row.id, + generatorId: 'discovery', + explanation: [{ + subjectType: 'artist', + subjectId: unfamiliarArtistIds[0], + predicate: 'credited_main_on', + objectType: 'track', + objectId: row.id, + fusedValue: 0.4, + }], + relevance: 0.4, + })); +} + +// --------------------------------------------------------------------------- +// 4. DEEP-DIVE — Albums from obsession artists, unplayed tracks first +// --------------------------------------------------------------------------- +async function deepDiveGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> { + const obsessedIds = ctx.beliefs + .filter(b => b.profile === 'obsession' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.3) + .map(b => b.entity_id); + + if (obsessedIds.length === 0) return []; + + const albumRes = await db.pgClient.query( + `SELECT al.id AS album_id, al.artist_id + FROM albums al + WHERE al.artist_id = ANY($1::uuid[]) + ORDER BY al.year ASC NULLS LAST, al.title ASC + LIMIT 20`, + [obsessedIds] + ); + + const candidates: Candidate[] = []; + + for (const album of albumRes.rows as { album_id: string; artist_id: string }[]) { + const trackRes = await db.pgClient.query( + `SELECT t.id + FROM tracks t + WHERE t.album_id = $1 AND t.state = 'LIBRARY' + AND NOT (t.id = ANY($2::uuid[])) + ORDER BY t.title ASC + LIMIT 5`, + [album.album_id, ctx.recentExclusions] + ); + + for (const row of trackRes.rows as { id: string }[]) { + candidates.push({ + trackId: row.id, + generatorId: 'deep-dive', + explanation: [{ + subjectType: 'artist', + subjectId: album.artist_id, + predicate: 'credited_main_on', + objectType: 'track', + objectId: row.id, + fusedValue: 0.7, + }], + relevance: 0.7, + }); + } + } + + return candidates; +} + +// --------------------------------------------------------------------------- +// 5. REVIVAL — Stale longterm affinity (last_reinforced > 90 days ago) +// --------------------------------------------------------------------------- +async function revivalGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> { + const staleRes = await db.pgClient.query( + `SELECT lb.entity_id AS artist_id, lb.value AS affinity + FROM listener_beliefs lb + WHERE lb.user_id = $1 + AND lb.profile = 'longterm' + AND lb.entity_type = 'artist' + AND lb.dimension = 'affinity' + AND lb.value > 0.3 + AND lb.last_reinforced_at < NOW() - INTERVAL '90 days' + ORDER BY lb.value DESC + LIMIT 20`, + [ctx.userId] + ); + + const staleArtists = staleRes.rows as { artist_id: string; affinity: number }[]; + if (staleArtists.length === 0) return []; + + const staleArtistIds = staleArtists.map(a => a.artist_id); + + const trackRes = await db.pgClient.query( + `SELECT id FROM ( + SELECT DISTINCT t.id + FROM tracks t + JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id + AND cf.predicate IN ('credited_main_on', 'featured_on') + AND cf.object_type = 'artist' + AND cf.object_id = ANY($1::uuid[]) + AND (cf.user_id = $2 OR cf.user_id = $3) + WHERE t.state = 'LIBRARY' + AND NOT (t.id = ANY($4::uuid[])) + ) sub + ORDER BY RANDOM() + LIMIT 20`, + [staleArtistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions] + ); + + return (trackRes.rows as { id: string }[]).map(row => ({ + trackId: row.id, + generatorId: 'revival', + explanation: [{ + subjectType: 'artist', + subjectId: staleArtistIds[0], + predicate: 'credited_main_on', + objectType: 'track', + objectId: row.id, + fusedValue: 0.6, + }], + relevance: 0.6, + })); +} + +// --------------------------------------------------------------------------- +// 6. NOVELTY — Recently released tracks by graph-adjacent artists +// --------------------------------------------------------------------------- +async function noveltyGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> { + const trustedIds = ctx.beliefs + .filter(b => b.entity_type === 'artist' && b.value > 0.3) + .map(b => b.entity_id); + + if (trustedIds.length === 0) return []; + + const res = await db.pgClient.query( + `SELECT id FROM ( + SELECT DISTINCT t.id, t.release_date + FROM tracks t + JOIN claim_fusion cf_edge ON cf_edge.subject_type = 'artist' + AND cf_edge.subject_id = ANY($2::uuid[]) + AND cf_edge.predicate IN ('same_scene_as', 'same_label_as', 'produced') + AND cf_edge.object_type = 'artist' + JOIN claim_fusion cf_track ON cf_track.subject_type = 'track' + AND cf_track.subject_id = t.id + AND cf_track.predicate IN ('credited_main_on', 'featured_on') + AND cf_track.object_type = 'artist' + AND cf_track.object_id = cf_edge.object_id + WHERE t.release_date IS NOT NULL + AND t.release_date >= NOW() - INTERVAL '60 days' + AND t.state = 'LIBRARY' + AND NOT (t.id = ANY($1::uuid[])) + ) sub + ORDER BY release_date DESC + LIMIT 20`, + [ctx.recentExclusions.length > 0 ? ctx.recentExclusions : ['00000000-0000-0000-0000-000000000000'], trustedIds] + ); + + return res.rows.map((row: { id: string }) => ({ + trackId: row.id, + generatorId: 'novelty', + relevance: 0.5, + explanation: [{ + subjectType: 'track', subjectId: row.id, + predicate: 'release_date', + objectType: 'date', objectId: 'recent', + fusedValue: 0.5, + }], + })); +} + +// --------------------------------------------------------------------------- +// 7. EXPERIMENTAL — Genres with high network distance from favourites +// --------------------------------------------------------------------------- +async function experimentalGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> { + const favArtistIds = ctx.beliefs + .filter(b => b.entity_type === 'artist' && b.value > 0.4) + .map(b => b.entity_id); + + if (favArtistIds.length === 0) return []; + + const favGenreIds = ctx.beliefs + .filter(b => b.entity_type === 'genre' && b.value > 0.2) + .map(b => b.entity_id); + + const result = await db.pgClient.query( + `WITH unfamiliar_genres AS ( + SELECT g.id, g.name, + (SELECT COUNT(*) FROM track_genre tg2 WHERE tg2.genre_id = g.id) AS track_count + FROM genre g + WHERE NOT (g.id = ANY($1::uuid[])) + AND EXISTS (SELECT 1 FROM track_genre tg WHERE tg.genre_id = g.id) + ORDER BY RANDOM() + LIMIT 3 + ), + candidate_tracks AS ( + SELECT DISTINCT t.id, tg.genre_id + FROM tracks t + JOIN track_genre tg ON tg.track_id = t.id + JOIN unfamiliar_genres ug ON ug.id = tg.genre_id + WHERE t.state = 'LIBRARY' + AND NOT (t.id = ANY($2::uuid[])) + LIMIT 30 + ) + SELECT ct.id, ct.genre_id + FROM candidate_tracks ct + ORDER BY RANDOM() + LIMIT 6`, + [ + favGenreIds.length > 0 ? favGenreIds : ['00000000-0000-0000-0000-000000000000'], + ctx.recentExclusions.length > 0 ? ctx.recentExclusions : ['00000000-0000-0000-0000-000000000000'], + ] + ); + + return result.rows.map((row: { id: string; genre_id: string }) => ({ + trackId: row.id, + generatorId: 'experimental', + relevance: 0.2, + explanation: [{ + subjectType: 'genre', subjectId: row.genre_id, + predicate: 'belongs_to_genre', + objectType: 'track', objectId: row.id, + fusedValue: 0.2, + }], + })); +} + +// --------------------------------------------------------------------------- +// 8. CONTEXTUAL — Contextual profile beliefs +// --------------------------------------------------------------------------- +async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> { + if (!ctx.state.context) return []; + + const contextualBeliefs = await db.getListenerBeliefs({ + userId: ctx.userId, + profile: 'contextual', + limit: 30, + orderBy: 'value', + order: 'DESC', + }); + + const targetArtistIds = contextualBeliefs + .filter(b => b.entity_type === 'artist' && b.value > 0.2) + .map(b => b.entity_id); + + if (targetArtistIds.length === 0) return []; + + const trackRes = await db.pgClient.query( + `SELECT id FROM ( + SELECT DISTINCT t.id + FROM tracks t + JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id + AND cf.predicate IN ('credited_main_on', 'featured_on') + AND cf.object_type = 'artist' + AND cf.object_id = ANY($1::uuid[]) + AND (cf.user_id = $2 OR cf.user_id = $3) + WHERE t.state = 'LIBRARY' + AND NOT (t.id = ANY($4::uuid[])) + ) sub + ORDER BY RANDOM() + LIMIT 15`, + [targetArtistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions] + ); + + return (trackRes.rows as { id: string }[]).map(row => ({ + trackId: row.id, + generatorId: 'contextual', + explanation: [{ + subjectType: 'artist', + subjectId: targetArtistIds[0], + predicate: 'credited_main_on', + objectType: 'track', + objectId: row.id, + fusedValue: 0.5, + }], + relevance: 0.5, + })); +} + +// --------------------------------------------------------------------------- +// All generators, ordered by priority (comfort first, experimental last) +// --------------------------------------------------------------------------- +export const ALL_GENERATORS: Generator[] = [ + comfortGenerator, + adjacentGenerator, + deepDiveGenerator, + revivalGenerator, + discoveryGenerator, + noveltyGenerator, + contextualGenerator, + experimentalGenerator, +]; diff --git a/backend/src/services/generators.test.ts b/backend/src/services/generators.test.ts new file mode 100644 index 0000000..14d6250 --- /dev/null +++ b/backend/src/services/generators.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, vi } from 'vitest'; +import { ALL_GENERATORS, type GeneratorContext, type Candidate } from './generators.service.js'; +import { DbService } from './db.service.js'; + +function makeMockDb(overrides: Record<string, any> = {}): DbService { + const mockQuery = vi.fn(); + return { + pgClient: { query: mockQuery }, + getListenerBeliefs: vi.fn().mockResolvedValue([]), + ...overrides, + } as unknown as DbService; +} + +function makeCtx(overrides: Partial<GeneratorContext> = {}): GeneratorContext { + return { + userId: '00000000-0000-0000-0000-000000000000', + seedTrackId: null, + seedArtistId: null, + beliefs: [], + recentExclusions: [], + toleranceMap: {}, + state: { energy: 0.5, lastArtistIds: [], lastGenreIds: [], context: null, noveltyHunger: 0.3, sessionAgeMin: 10 }, + ...overrides, + }; +} + +// Index generators by name for easy test access +const generatorByName: Record<string, (typeof ALL_GENERATORS)[0]> = { + comfort: ALL_GENERATORS[0], + adjacent: ALL_GENERATORS[1], + deepDive: ALL_GENERATORS[2], + revival: ALL_GENERATORS[3], + discovery: ALL_GENERATORS[4], + novelty: ALL_GENERATORS[5], + contextual: ALL_GENERATORS[6], + experimental: ALL_GENERATORS[7], +}; + +describe('generators', () => { + describe('comfort', () => { + it('returns tracks for artists with affinity > 0.5', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 'track-1' }, { id: 'track-2' }] }); + const ctx = makeCtx({ + beliefs: [ + { entity_type: 'artist', entity_id: 'artist-1', value: 0.8, confidence: 0.9, profile: 'longterm', dimension: 'affinity' } as any, + ], + }); + const results = await generatorByName.comfort(db, ctx); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0]).toHaveProperty('trackId'); + expect(results[0]).toHaveProperty('generatorId', 'comfort'); + expect(results[0].explanation.length).toBeGreaterThanOrEqual(1); + }); + + it('returns empty when no high-affinity artists', async () => { + const db = makeMockDb(); + const ctx = makeCtx({ beliefs: [ { entity_type: 'artist', entity_id: 'a1', value: 0.3, profile: 'longterm', dimension: 'affinity' } as any ] }); + const results = await generatorByName.comfort(db, ctx); + expect(results).toHaveLength(0); + }); + }); + + describe('adjacent', () => { + it('returns tracks from graph walks', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValueOnce({ rows: [{ reached_artist_id: 'artist-2' }] }); + (db.pgClient.query as any).mockResolvedValueOnce({ rows: [{ id: 'track-3' }] }); + const ctx = makeCtx({ seedTrackId: 'track-1', seedArtistId: 'artist-1' }); + const results = await generatorByName.adjacent(db, ctx); + if (results.length > 0) { + expect(results[0].explanation.length).toBeGreaterThanOrEqual(1); + } + }); + + it('returns empty when no seed artist', async () => { + const results = await generatorByName.adjacent(makeMockDb(), makeCtx()); + expect(results).toHaveLength(0); + }); + }); + + describe('discovery', () => { + it('respects novelty_tolerance cap', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ rows: [{ artist_id: 'a1' }, { artist_id: 'a2' }] }); + (db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 't1' }] }); + const ctx = makeCtx({ + beliefs: [ { entity_type: 'artist', entity_id: 'trusted-1', value: 0.5, confidence: 0.8, profile: 'longterm', dimension: 'affinity' } as any ], + toleranceMap: { novelty_tolerance: 0.1 }, + }); + const results = await generatorByName.discovery(db, ctx); + const maxCandidates = Math.max(2, Math.floor(20 * 0.1)); + expect(results.length).toBeLessThanOrEqual(maxCandidates); + }); + }); + + describe('deepDive', () => { + it('returns album-ordered tracks for obsession artists', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValueOnce({ rows: [{ album_id: 'alb-1', artist_id: 'a1', title: 'Album 1' }] }); + (db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 't1' }, { id: 't2' }] }); + const ctx = makeCtx({ + beliefs: [ { entity_type: 'artist', entity_id: 'a1', dimension: 'affinity', value: 0.6, profile: 'obsession' } as any ], + }); + const results = await generatorByName.deepDive(db, ctx); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0].generatorId).toBe('deep-dive'); + }); + }); + + describe('revival', () => { + it('returns tracks for stale high-affinity artists', async () => { + const db = makeMockDb(); + // First query: stale listener_beliefs + (db.pgClient.query as any).mockResolvedValueOnce({ + rows: [{ artist_id: 'a1', affinity: 0.7 }] + }); + // Second query: tracks by those artists + (db.pgClient.query as any).mockResolvedValue({ + rows: [{ id: 't1' }] + }); + const ctx = makeCtx({ + beliefs: [ { entity_type: 'artist', entity_id: 'a1', dimension: 'affinity', value: 0.5, profile: 'forgotten' } as any ], + }); + const results = await generatorByName.revival(db, ctx); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0].generatorId).toBe('revival'); + }); + }); + describe('novelty', () => { + it('returns recent tracks', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 't1' }] }); + const ctx = makeCtx({ + beliefs: [ { entity_type: 'artist', entity_id: 'trusted-1', value: 0.5, profile: 'longterm', dimension: 'affinity' } as any ], + }); + const results = await generatorByName.novelty(db, ctx); + if (results.length > 0) { + expect(results[0].generatorId).toBe('novelty'); + } + }); + }); + + describe('experimental', () => { + it('returns tracks from unfamiliar genres', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 't1', genre_id: 'g1' }] }); + const ctx = makeCtx({ + beliefs: [ { entity_type: 'artist', entity_id: 'a1', value: 0.6, profile: 'longterm', dimension: 'affinity' } as any ], + }); + const results = await generatorByName.experimental(db, ctx); + expect(results).toBeDefined(); + }); + }); + + describe('contextual', () => { + it('returns tracks matching context when set', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValueOnce({ rows: [{ id: 't1' }] }); + const ctx = makeCtx({ + state: { energy: 0.5, lastArtistIds: [], lastGenreIds: [], context: 'coding', noveltyHunger: 0.3, sessionAgeMin: 10 }, + }); + const results = await generatorByName.contextual(db, ctx); + expect(results).toBeDefined(); + }); + + it('returns empty when no context set', async () => { + const results = await generatorByName.contextual(makeMockDb(), makeCtx()); + expect(results).toHaveLength(0); + }); + }); +}); + +describe('ALL_GENERATORS', () => { + it('contains 8 generators', () => { + expect(ALL_GENERATORS).toHaveLength(8); + ALL_GENERATORS.forEach(g => expect(typeof g).toBe('function')); + }); +}); diff --git a/backend/src/services/image-enrichment.service.ts b/backend/src/services/image-enrichment.service.ts new file mode 100644 index 0000000..bad4cbb --- /dev/null +++ b/backend/src/services/image-enrichment.service.ts @@ -0,0 +1,105 @@ +import { DbService } from './db.service.js'; + +export class ImageEnrichmentService { + private sourcePriority: Record<string, number> = { + cover_art_archive: 1, + theaudiodb: 2, + fanart: 3, + deezer: 4, + discogs: 5, + lastfm: 6, + }; + + constructor(private db: DbService) {} + + // --------------------------------------------------------------- + // Phase 4 — Fetch image candidates from all sources + // --------------------------------------------------------------- + async fetchImagesForArtist(artistId: string): Promise<number> { + const artistRes = await this.db.pgClient.query<{ id: string; name: string; mbid: string | null; discogs_id: string | null }>( + `SELECT id, name, mbid, discogs_id FROM artists WHERE id = $1`, + [artistId] + ); + const artist = artistRes.rows[0]; + if (!artist) return 0; + + const sources: { key: string; condition: string }[] = [ + { key: 'cover_art_archive', condition: artist.mbid ? 'mbid present' : 'no mbid' }, + { key: 'deezer', condition: 'always' }, + { key: 'discogs', condition: artist.discogs_id ? 'discogs_id present' : 'no discogs_id' }, + { key: 'lastfm', condition: 'always' }, + ]; + + let count = 0; + + for (const src of sources) { + const res = await this.db.pgClient.query( + `INSERT INTO image_candidates (entity_type, entity_id, source, url, width) + VALUES ('artist', $1, $2, NULL, NULL) + ON CONFLICT (entity_type, entity_id, source) DO NOTHING + RETURNING 1 AS ins`, + [artistId, src.key] + ); + if (res.rows.length > 0) count++; + } + + return count; + } + + async fetchImagesForAlbum(albumId: string): Promise<number> { + const sources = ['cover_art_archive', 'itunes', 'deezer', 'discogs']; + let count = 0; + + for (const source of sources) { + const res = await this.db.pgClient.query( + `INSERT INTO image_candidates (entity_type, entity_id, source, url, width) + VALUES ('album', $1, $2, NULL, NULL) + ON CONFLICT (entity_type, entity_id, source) DO NOTHING + RETURNING 1 AS ins`, + [albumId, source] + ); + if (res.rows.length > 0) count++; + } + + return count; + } + + async selectBestImage(entityType: string, entityId: string): Promise<string | null> { + const candidates = await this.db.pgClient.query<{ url: string; source: string; verified: boolean }>( + `SELECT url, source, verified + FROM image_candidates + WHERE entity_type = $1 AND entity_id = $2 AND url IS NOT NULL + ORDER BY + CASE source + WHEN 'cover_art_archive' THEN 1 + WHEN 'theaudiodb' THEN 2 + WHEN 'fanart' THEN 3 + WHEN 'deezer' THEN 4 + WHEN 'discogs' THEN 5 + WHEN 'lastfm' THEN 6 + ELSE 99 + END, + verified DESC, + width DESC NULLS LAST + LIMIT 1`, + [entityType, entityId] + ); + + const best = candidates.rows[0]; + if (!best) return null; + + if (entityType === 'artist') { + await this.db.pgClient.query( + `UPDATE artists SET image_path = $1 WHERE id = $2`, + [best.url, entityId] + ); + } else if (entityType === 'album') { + await this.db.pgClient.query( + `UPDATE albums SET artwork_id = $1 WHERE id = $2`, + [best.url, entityId] + ); + } + + return best.url; + } +} diff --git a/backend/src/services/job.service.ts b/backend/src/services/job.service.ts new file mode 100644 index 0000000..7e6da7b --- /dev/null +++ b/backend/src/services/job.service.ts @@ -0,0 +1,133 @@ +import { Queue, Job } from 'bullmq'; +import { MetadataRefreshJob, AudioAnalysisJob, CleanupJob, LibraryScanJob, ReindexTracksJob, ReprocessArtistsJob } from '../types/job.types.js'; + +export interface JobServiceConfig { + redisUrl: string; +} + +export const QUEUE_NAME = 'muzick-queue'; + +export interface QueueStats { + waiting: number; + active: number; + completed: number; + failed: number; + delayed: number; + paused: number; +} + +export interface JobHistoryEntry { + id: string; + name: string; + data: Record<string, unknown>; + timestamp: number; + finishedOn?: number; + failedReason?: string; + returnvalue?: unknown; +} + +export class JobService { + private queue: Queue; + + constructor(config: JobServiceConfig) { + this.queue = new Queue(QUEUE_NAME, { + connection: { + url: config.redisUrl, + }, + }); + } + + async enqueueMetadataRefresh(trackId: string, type: 'full' | 'partial') { + const payload: MetadataRefreshJob = { trackId, refreshType: type }; + await this.queue.add('metadata_refresh', payload); + } + + async enqueueAudioAnalysis(trackId: string, features: string[]) { + const payload: AudioAnalysisJob = { trackId, features }; + await this.queue.add('audio_analysis', payload); + } + + async enqueueCleanup(reason: 'expired' | 'manual', targetFiles: string[]) { + const payload: CleanupJob = { reason, targetFiles }; + await this.queue.add('cleanup', payload); + } + + async enqueueLibraryScan(directory: string) { + const payload: LibraryScanJob = { directory }; + await this.queue.add('scan_library', payload); + } + + async enqueueReindexTracks() { + const payload: ReindexTracksJob = {}; + await this.queue.add('reindex_tracks', payload); + } + + async enqueueReprocessArtists() { + const payload: ReprocessArtistsJob = { batchSize: 100, offset: 0 }; + await this.queue.add('reprocess_artists', payload); + } + + /** + * Enqueue metadata_refresh jobs for a batch of track IDs. Used by the + * /admin/reenrich-tracks endpoint to re-canonicalize metadata (artist names, + * album titles, MBIDs, cover art) without re-scanning files from disk. + * + * Each job is deduped by `jobId: meta-<trackId>` so re-running the endpoint + * doesn't stack duplicate jobs. Old completed/failed jobs with the same ID + * are removed first so re-enrichment actually works (BullMQ otherwise treats + * existing jobIds as duplicates and silently skips them). + */ + async enqueueMetadataRefreshBatch(trackIds: string[]): Promise<number> { + let enqueued = 0; + for (const trackId of trackIds) { + const jobId = `meta-${trackId}`; + await this.queue.remove(jobId).catch(() => {}); + const payload: MetadataRefreshJob = { trackId, refreshType: 'full' }; + await this.queue.add('metadata_refresh', payload, { + jobId, + removeOnComplete: { age: 86400, count: 10000 }, + removeOnFail: { age: 86400, count: 10000 }, + }); + enqueued++; + } + return enqueued; + } + + async getQueueStats(): Promise<QueueStats> { + const [waiting, active, completed, failed, delayed] = await Promise.all([ + this.queue.getWaitingCount(), + this.queue.getActiveCount(), + this.queue.getCompletedCount(), + this.queue.getFailedCount(), + this.queue.getDelayedCount(), + ]); + return { waiting, active, completed, failed, delayed, paused: 0 }; + } + + async getJobHistory(limit = 100): Promise<JobHistoryEntry[]> { + // Get jobs from completed and failed queues (most recent first) + const [completedJobs, failedJobs] = await Promise.all([ + this.queue.getJobs(['completed'], 0, limit), + this.queue.getJobs(['failed'], 0, limit), + ]); + + const allJobs = [...completedJobs, ...failedJobs].map(job => ({ + id: job.id as string, + name: job.name, + data: job.data as Record<string, unknown>, + timestamp: job.timestamp, + finishedOn: job.finishedOn, + failedReason: job.failedReason, + returnvalue: job.returnvalue, + })); + + // Sort by timestamp descending (most recent first) + allJobs.sort((a, b) => b.timestamp - a.timestamp); + + return allJobs.slice(0, limit); + } + + async close() { + await this.queue.close(); + } +} diff --git a/backend/src/services/search.service.ts b/backend/src/services/search.service.ts new file mode 100644 index 0000000..10d10b5 --- /dev/null +++ b/backend/src/services/search.service.ts @@ -0,0 +1,94 @@ +import { Client } from 'typesense'; + +export interface SearchServiceConfig { + host: string; + port: number; + protocol: 'http' | 'https'; + apiKey: string; +} + +export class SearchService { + private client: Client; + private ready = false; + + constructor(config: SearchServiceConfig) { + this.client = new Client({ + nodes: [{ + host: config.host, + port: config.port, + protocol: config.protocol, + }], + apiKey: config.apiKey, + }); + } + + get isReady(): boolean { + return this.ready; + } + + async search(collection: string, query: string, options: any = {}) { + const searchParameters = { + 'q': query, + 'query_by': options.query_by || 'title,artist', + ...options, + }; + + return await this.client.collections(collection).documents().search(searchParameters); + } + + /** + * Create the 'tracks' collection schema if it does not already exist. + * Gracefully handles Typesense not being ready yet (503) — the collection + * can be created later by calling ensureCollection again or via the admin + * reindex endpoint. Search falls back to Postgres ILIKE when Typesense is + * unavailable, so a missing collection is never a hard failure. + */ + async ensureCollection(): Promise<void> { + const collectionSchema = { + name: 'tracks', + fields: [ + { name: 'id', type: 'string' as const }, + { name: 'title', type: 'string' as const }, + { name: 'artist', type: 'string' as const }, + { name: 'album', type: 'string' as const }, + { name: 'duration', type: 'int32' as const }, + { name: 'play_count', type: 'int32' as const }, + { name: 'genre', type: 'string[]' as const, facet: true }, + { name: 'source_type', type: 'string' as const }, + ], + }; + + // First check if the collection already exists (retrieve succeeds). + try { + await this.client.collections(collectionSchema.name).retrieve(); + this.ready = true; + return; + } catch (checkErr: any) { + // 404 means collection doesn't exist — proceed to create. + // 503 means Typesense isn't ready yet — skip creation, search falls back. + if (checkErr?.httpStatus === 503) { + console.warn('[SearchService] Typesense not ready yet (503). Search will use Postgres ILIKE fallback.'); + return; + } + } + + // Collection doesn't exist — create it. + try { + await this.client.collections().create(collectionSchema); + this.ready = true; + } catch (createErr: any) { + // 409 / "already exists" — another process created it between our check and create. + if (createErr?.message?.includes('already exists')) { + this.ready = true; + return; + } + // 503 — Typesense not ready yet, not a hard failure. + if (createErr?.httpStatus === 503) { + console.warn('[SearchService] Typesense not ready yet (503). Search will use Postgres ILIKE fallback.'); + return; + } + // Unexpected error — log and continue; search falls back to Postgres. + console.warn('[SearchService] Failed to create tracks collection:', createErr?.message ?? createErr); + } + } +} diff --git a/backend/src/services/session-director.service.ts b/backend/src/services/session-director.service.ts new file mode 100644 index 0000000..722954e --- /dev/null +++ b/backend/src/services/session-director.service.ts @@ -0,0 +1,928 @@ +import { DbService, ListenerBelief } from './db.service.js'; +import { Candidate, GeneratorContext, Generator, ALL_GENERATORS } from './generators.service.js'; + +export interface FatigueState { + artist: Map<string, number>; + genre: Map<string, number>; + language: Map<string, number>; + track: Map<string, number>; + vocal: number; +} + +export interface RecentPlay { + trackId: string; + artistId: string | null; + genreId: string | null; + bpm: number | null; + energy: number | null; + language: string | null; + vocal: boolean; + decade: number | null; + valence: number | null; +} + +export interface DiversityBudget { + dimension: string; + budgetShare: number; + horizonMin: number; + spent: number; +} + +const W_ENJOY = 1.0; +const W_FATIGUE = 0.4; +const W_DIVERSITY = 0.3; +const W_ENTROPY = 0.2; +const W_REPETITION = 0.5; + +export class SessionDirector { + constructor(private db: DbService) {} + + // --------------------------------------------------------------- + // D.1 — Build listener state vector + // --------------------------------------------------------------- + async buildState(userId: string, sessionId?: string): Promise<GeneratorContext['state']> { + let savedState: GeneratorContext['state'] | null = null; + + if (sessionId) { + const res = await this.db.pgClient.query( + 'SELECT * FROM session_state WHERE session_id = $1 AND user_id = $2', + [sessionId, userId] + ); + if (res.rows[0]) { + const row = res.rows[0] as { state_vector: Record<string, unknown>; context: string | null; started_at: Date }; + savedState = { + energy: (row.state_vector?.energy as number) ?? 0.5, + lastArtistIds: (row.state_vector?.lastArtistIds as string[]) ?? [], + lastGenreIds: (row.state_vector?.lastGenreIds as string[]) ?? [], + context: row.context, + noveltyHunger: (row.state_vector?.noveltyHunger as number) ?? 0.3, + sessionAgeMin: row.started_at + ? (Date.now() - new Date(row.started_at).getTime()) / 60000 + : 0, + }; + } + } + + if (!savedState) { + const latest = await this.db.getLatestSessionState(userId); + if (latest) { + savedState = { + energy: (latest.state_vector?.energy as number) ?? 0.5, + lastArtistIds: (latest.state_vector?.lastArtistIds as string[]) ?? [], + lastGenreIds: (latest.state_vector?.lastGenreIds as string[]) ?? [], + context: latest.context, + noveltyHunger: (latest.state_vector?.noveltyHunger as number) ?? 0.3, + sessionAgeMin: latest.started_at + ? (Date.now() - new Date(latest.started_at).getTime()) / 60000 + : 0, + }; + } + } + + // Compute fresh energy from last 5 completed plays + const energyRes = await this.db.pgClient.query( + `SELECT COALESCE(AVG(taf.energy), 0.5) AS energy + FROM ( + SELECT ph.track_id + FROM play_history ph + WHERE ph.user_id = $1 AND ph.completed = true + ORDER BY ph.played_at DESC + LIMIT 5 + ) recent + JOIN track_audio_features taf ON taf.track_id = recent.track_id + WHERE taf.energy IS NOT NULL`, + [userId] + ); + const energy = (energyRes.rows[0]?.energy as number) ?? 0.5; + + // Read novelty hunger from discovery profile + const noveltyRes = await this.db.pgClient.query( + `SELECT value FROM listener_beliefs + WHERE user_id = $1 AND profile = 'discovery' AND dimension = 'novelty_tolerance' + LIMIT 1`, + [userId] + ); + const noveltyHunger = (noveltyRes.rows[0]?.value as number) ?? 0.3; + + // Last distinct artist IDs from recent completed plays. + // Use a subquery to order first, then DISTINCT — avoids PG's rule that + // DISTINCT + ORDER BY expressions must appear in the select list. + const lastArtistsRes = await this.db.pgClient.query( + `SELECT DISTINCT artist_id FROM ( + SELECT ta.artist_id + FROM play_history ph + JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main' + WHERE ph.user_id = $1 AND ph.completed = true + ORDER BY ph.played_at DESC + LIMIT 40 + ) recent + LIMIT 10`, + [userId] + ); + const lastArtistIds = lastArtistsRes.rows.map((r: { artist_id: string }) => r.artist_id); + + // Last distinct genre IDs + const lastGenresRes = await this.db.pgClient.query( + `SELECT DISTINCT genre_id FROM ( + SELECT tg.genre_id + FROM play_history ph + JOIN track_genre tg ON tg.track_id = ph.track_id + WHERE ph.user_id = $1 AND ph.completed = true + ORDER BY ph.played_at DESC + LIMIT 40 + ) recent + LIMIT 10`, + [userId] + ); + const lastGenreIds = lastGenresRes.rows.map((r: { genre_id: string }) => r.genre_id); + + const age = savedState?.sessionAgeMin ?? 0; + + return { + energy, + lastArtistIds, + lastGenreIds, + context: savedState?.context ?? null, + noveltyHunger, + sessionAgeMin: age, + }; + } + + // --------------------------------------------------------------- + // D.2 — Fatigue model + // --------------------------------------------------------------- + async computeFatigue(userId: string): Promise<FatigueState> { + // Track fatigue: last 7 days, decay half-life 30d (2592000 seconds) + const TRACK_DECAY_SEC = 30 * 24 * 3600; + const trackRes = await this.db.pgClient.query( + `SELECT ph.track_id, + LEAST(1.0, SUM(EXP(-EXTRACT(EPOCH FROM (NOW() - ph.played_at)) / $2::float8))) AS fatigue + FROM play_history ph + WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '7 days' AND ph.completed = true + GROUP BY ph.track_id`, + [userId, TRACK_DECAY_SEC] + ); + const track = new Map<string, number>(); + for (const row of trackRes.rows as { track_id: string; fatigue: number }[]) { + track.set(row.track_id, row.fatigue); + } + + // Artist fatigue: last 24h, decay half-life 8h (28800 seconds) + const ARTIST_DECAY_SEC = 8 * 3600; + const artistRes = await this.db.pgClient.query( + `SELECT ta.artist_id, + LEAST(1.0, SUM(EXP(-EXTRACT(EPOCH FROM (NOW() - ph.played_at)) / $2::float8))) AS fatigue + FROM play_history ph + JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main' + WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '24 hours' AND ph.completed = true + GROUP BY ta.artist_id`, + [userId, ARTIST_DECAY_SEC] + ); + const artist = new Map<string, number>(); + for (const row of artistRes.rows as { artist_id: string; fatigue: number }[]) { + artist.set(row.artist_id, row.fatigue); + } + + // Genre fatigue: last 24h, decay half-life 8h + const genreRes = await this.db.pgClient.query( + `SELECT tg.genre_id, + LEAST(1.0, SUM(EXP(-EXTRACT(EPOCH FROM (NOW() - ph.played_at)) / $2::float8))) AS fatigue + FROM play_history ph + JOIN track_genre tg ON tg.track_id = ph.track_id + WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '24 hours' AND ph.completed = true + GROUP BY tg.genre_id`, + [userId, ARTIST_DECAY_SEC] + ); + const genre = new Map<string, number>(); + for (const row of genreRes.rows as { genre_id: string; fatigue: number }[]) { + genre.set(row.genre_id, row.fatigue); + } + + // Language fatigue: last 2h, decay half-life 1h (3600 seconds) + const LANG_DECAY_SEC = 3600; + const langRes = await this.db.pgClient.query( + `SELECT tl.language, + LEAST(1.0, SUM(EXP(-EXTRACT(EPOCH FROM (NOW() - ph.played_at)) / $2::float8))) AS fatigue + FROM play_history ph + JOIN track_lyrics tl ON tl.track_id = ph.track_id + WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '2 hours' AND ph.completed = true + AND tl.language IS NOT NULL + GROUP BY tl.language`, + [userId, LANG_DECAY_SEC] + ); + const language = new Map<string, number>(); + for (const row of langRes.rows as { language: string; fatigue: number }[]) { + language.set(row.language, row.fatigue); + } + + // Vocal fatigue: fraction of last 2h plays that are vocal (instrumentalness < 0.5) + const vocalRes = await this.db.pgClient.query( + `SELECT CASE WHEN COUNT(*) = 0 THEN 0.5 + ELSE COUNT(*) FILTER (WHERE COALESCE(taf.instrumentalness, 0) < 0.5)::float8 / COUNT(*)::float8 + END AS vocal_fatigue + FROM play_history ph + LEFT JOIN track_audio_features taf ON taf.track_id = ph.track_id + WHERE ph.user_id = $1 AND ph.played_at > NOW() - INTERVAL '2 hours' AND ph.completed = true`, + [userId] + ); + const vocal = (vocalRes.rows[0]?.vocal_fatigue as number) ?? 0.5; + + return { artist, genre, language, track, vocal }; + } + + // --------------------------------------------------------------- + // D.3 — Diversity budgets + // --------------------------------------------------------------- + async getBudgets(userId: string): Promise<DiversityBudget[]> { + const res = await this.db.pgClient.query( + 'SELECT * FROM diversity_budgets WHERE user_id = $1 ORDER BY dimension', + [userId] + ); + + let rows: { dimension: string; budget_share: number; horizon_min: number }[]; + if (res.rows.length === 0) { + await this.db.seedDefaultDiversityBudgets(userId); + const res2 = await this.db.pgClient.query( + 'SELECT * FROM diversity_budgets WHERE user_id = $1 ORDER BY dimension', + [userId] + ); + rows = res2.rows; + } else { + rows = res.rows; + } + + const budgets: DiversityBudget[] = []; + for (const row of rows) { + const spent = await this.calcBudgetSpent(userId, row.dimension, row.horizon_min); + budgets.push({ + dimension: row.dimension, + budgetShare: row.budget_share, + horizonMin: row.horizon_min, + spent, + }); + } + return budgets; + } + + private async calcBudgetSpent(userId: string, dimension: string, horizonMin: number): Promise<number> { + const interval = `${horizonMin} minutes`; + + switch (dimension) { + case 'artist': { + const res = await this.db.pgClient.query( + `WITH sub AS ( + SELECT COUNT(*) AS cnt + FROM play_history ph + JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main' + WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true + GROUP BY ta.artist_id + ) + SELECT COALESCE(MAX(cnt)::float8 / NULLIF((SELECT SUM(cnt) FROM sub), 0), 0) AS spent + FROM sub`, + [userId, interval] + ); + return (res.rows[0]?.spent as number) ?? 0; + } + case 'genre': { + const res = await this.db.pgClient.query( + `WITH sub AS ( + SELECT COUNT(*) AS cnt + FROM play_history ph + JOIN track_genre tg ON tg.track_id = ph.track_id + WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true + GROUP BY tg.genre_id + ) + SELECT COALESCE(MAX(cnt)::float8 / NULLIF((SELECT SUM(cnt) FROM sub), 0), 0) AS spent + FROM sub`, + [userId, interval] + ); + return (res.rows[0]?.spent as number) ?? 0; + } + case 'language': { + const res = await this.db.pgClient.query( + `WITH sub AS ( + SELECT tl.language, COUNT(*) AS cnt + FROM play_history ph + JOIN track_lyrics tl ON tl.track_id = ph.track_id + WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true + AND tl.language IS NOT NULL + GROUP BY tl.language + ) + SELECT COALESCE(MAX(cnt)::float8 / NULLIF((SELECT SUM(cnt) FROM sub), 0), 0) AS spent + FROM sub`, + [userId, interval] + ); + return (res.rows[0]?.spent as number) ?? 0; + } + case 'instrumental': { + const res = await this.db.pgClient.query( + `SELECT COALESCE( + COUNT(*) FILTER (WHERE COALESCE(taf.instrumentalness, 0) > 0.5)::float8 / NULLIF(COUNT(*), 0), + 0) AS spent + FROM play_history ph + LEFT JOIN track_audio_features taf ON taf.track_id = ph.track_id + WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true`, + [userId, interval] + ); + return (res.rows[0]?.spent as number) ?? 0; + } + case 'new_artist': { + const res = await this.db.pgClient.query( + `WITH recent_artists AS ( + SELECT DISTINCT ta.artist_id + FROM play_history ph + JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main' + WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true + ) + SELECT COALESCE( + SUM(CASE WHEN NOT EXISTS ( + SELECT 1 FROM play_history ph3 + JOIN track_artists_v2 ta3 ON ta3.track_id = ph3.track_id AND ta3.role = 'main' + WHERE ph3.user_id = $1 AND ph3.played_at <= NOW() - $2::interval + AND ta3.artist_id = ra.artist_id + ) THEN 1 ELSE 0 END)::float8 / NULLIF(COUNT(*), 0), + 0) AS spent + FROM recent_artists ra`, + [userId, interval] + ); + return (res.rows[0]?.spent as number) ?? 0; + } + case 'favorite': { + const res = await this.db.pgClient.query( + `SELECT COALESCE( + COUNT(*) FILTER (WHERE f.track_id IS NOT NULL)::float8 / NULLIF(COUNT(*), 0), + 0) AS spent + FROM play_history ph + LEFT JOIN favorites f ON f.track_id = ph.track_id AND f.user_id = $1 + WHERE ph.user_id = $1 AND ph.played_at > NOW() - $2::interval AND ph.completed = true`, + [userId, interval] + ); + return (res.rows[0]?.spent as number) ?? 0; + } + default: + return 0; + } + } + + // --------------------------------------------------------------- + // D.4 — Arc selection + // --------------------------------------------------------------- + pickArc(state: GeneratorContext['state']): string { + if (state.energy < 0.3) return 'late-night'; + if (state.energy > 0.6 && state.noveltyHunger > 0.5) return 'discovery'; + if (state.energy > 0.6) return 'energetic'; + return 'comfort'; + } + + getArcSlots(arcType: string, count: number): { position: number; role: string }[] { + const pattern = this.getArcPattern(arcType); + const slots: { position: number; role: string }[] = []; + for (let i = 0; i < count; i++) { + slots.push({ position: i, role: pattern[i % pattern.length] }); + } + return slots; + } + + private getArcPattern(arcType: string): string[] { + switch (arcType) { + case 'comfort': + return ['known', 'known', 'known', 'known', 'adjacent', 'adjacent', 'adjacent', 'adjacent', 'favorite', 'favorite']; + case 'discovery': + return ['favorite', 'similar', 'new', 'favorite']; + case 'energetic': + return ['medium', 'medium', 'high', 'high', 'high', 'peak', 'cooldown', 'cooldown']; + case 'late-night': + return ['soft', 'soft', 'ambient', 'ambient', 'acoustic', 'slow']; + default: + return ['known', 'known', 'known', 'known', 'adjacent', 'adjacent', 'adjacent', 'adjacent', 'favorite', 'favorite']; + } + } + + private roleToGeneratorIds(role: string): string[] { + switch (role) { + case 'known': + case 'medium': + case 'soft': + case 'acoustic': + case 'slow': + case 'cooldown': + return ['comfort']; + case 'adjacent': + case 'similar': + return ['adjacent']; + case 'favorite': + return ['deep-dive', 'comfort']; + case 'new': + case 'high': + return ['discovery']; + case 'peak': + return ['deep-dive', 'contextual']; + case 'ambient': + return ['contextual', 'comfort']; + default: + return ['comfort']; + } + } + + // --------------------------------------------------------------- + // D.5 — Entropy, anti-loop + // --------------------------------------------------------------- + computeEntropy(candidates: Candidate[]): number { + if (candidates.length === 0) return 0; + const artistCounts = new Map<string, number>(); + for (const c of candidates) { + const mainEdge = c.explanation.find( + e => e.subjectType === 'artist' || e.objectType === 'artist' + ); + const key = mainEdge?.subjectId ?? mainEdge?.objectId ?? 'unknown'; + artistCounts.set(key, (artistCounts.get(key) ?? 0) + 1); + } + const n = candidates.length; + let hhi = 0; + for (const count of artistCounts.values()) { + const share = count / n; + hhi += share * share; + } + return hhi; + } + + async detectAntiLoop( + state: GeneratorContext['state'], + fatigue: FatigueState, + budgets: DiversityBudget[], + recentPlays: RecentPlay[] + ): Promise<string | null> { + const n = recentPlays.length; + if (n < 3) return null; + + // 1. ARTIST: single artist > 30% of recent plays + const artistCounts = new Map<string, number>(); + for (const p of recentPlays) { + if (p.artistId) artistCounts.set(p.artistId, (artistCounts.get(p.artistId) ?? 0) + 1); + } + for (const count of artistCounts.values()) { + if (count / n > 0.3) return 'artist'; + } + + // 2. GENRE: single genre > 40% of recent plays + const genreCounts = new Map<string, number>(); + for (const p of recentPlays) { + if (p.genreId) genreCounts.set(p.genreId, (genreCounts.get(p.genreId) ?? 0) + 1); + } + for (const count of genreCounts.values()) { + if (count / n > 0.4) return 'genre'; + } + + // 3. LANGUAGE: single language > 50% of recent plays + const langCounts = new Map<string, number>(); + for (const p of recentPlays) { + if (p.language) langCounts.set(p.language, (langCounts.get(p.language) ?? 0) + 1); + } + for (const count of langCounts.values()) { + if (count / n > 0.5) return 'language'; + } + + // 4. ENERGY: >60% of plays in same energy quartile + const energies = recentPlays.filter(p => p.energy != null).map(p => p.energy!); + if (energies.length >= 3) { + const quartileCounts = [0, 0, 0, 0]; + for (const e of energies) { + const q = Math.min(Math.floor(e / 0.25), 3); + quartileCounts[q]++; + } + if (Math.max(...quartileCounts) / energies.length > 0.6) return 'energy'; + } + + // 5. BPM: all plays within 20 BPM of each other + const bpms = recentPlays.filter(p => p.bpm != null).map(p => p.bpm!); + if (bpms.length >= 3) { + const bpmMin = Math.min(...bpms); + const bpmMax = Math.max(...bpms); + if (bpmMax - bpmMin <= 20) return 'bpm'; + } + + // 6. VOCAL: >80% all-vocal or all-instrumental + if (n >= 3) { + const vocalCount = recentPlays.filter(p => p.vocal).length; + const vocalRatio = vocalCount / n; + if (vocalRatio > 0.8 || vocalRatio < 0.2) return 'vocal'; + } + + // 7. DECADE: >50% from same decade + const decadeCounts = new Map<number, number>(); + for (const p of recentPlays) { + if (p.decade != null) decadeCounts.set(p.decade, (decadeCounts.get(p.decade) ?? 0) + 1); + } + for (const count of decadeCounts.values()) { + if (count / n > 0.5) return 'decade'; + } + + // 8. PRODUCER: single producer > 3 tracks + const trackIds = recentPlays.map(p => p.trackId).filter(Boolean); + if (trackIds.length > 0) { + const prodRes = await this.db.pgClient.query( + `SELECT c.object_id + FROM claims c + WHERE c.predicate = 'produced' + AND c.subject_id = ANY($1::uuid[]) + GROUP BY c.object_id + HAVING COUNT(DISTINCT c.subject_id) > 3`, + [trackIds] + ); + if (prodRes.rows.length > 0) return 'producer'; + } + + // 9. LABEL: single label > 3 tracks + if (trackIds.length > 0) { + const labelRes = await this.db.pgClient.query( + `SELECT c.object_id + FROM claims c + WHERE c.predicate = 'same_label_as' + AND c.subject_id = ANY($1::uuid[]) + GROUP BY c.object_id + HAVING COUNT(DISTINCT c.subject_id) > 3`, + [trackIds] + ); + if (labelRes.rows.length > 0) return 'label'; + } + + // 10. MOOD: all plays same mood (valence > 0.5 = positive, <= 0.5 = negative) + const valences = recentPlays.filter(p => p.valence != null).map(p => p.valence!); + if (valences.length >= 3) { + const positiveCount = valences.filter(v => v > 0.5).length; + if (positiveCount === valences.length || positiveCount === 0) return 'mood'; + } + + return null; + } + + // --------------------------------------------------------------- + // D.6 — Repetition rules + // --------------------------------------------------------------- + async checkRepetition(trackId: string, artistId: string, userId: string): Promise<boolean> { + const rulesRes = await this.db.pgClient.query( + 'SELECT dimension, min_distance FROM repetition_rules WHERE user_id = $1', + [userId] + ); + + const ruleMap = new Map<string, number>(); + for (const row of rulesRes.rows as { dimension: string; min_distance: number }[]) { + ruleMap.set(row.dimension, row.min_distance); + } + + const trackMin = ruleMap.get('track') ?? 120; + + if (trackMin > 0) { + const res = await this.db.pgClient.query( + `SELECT 1 FROM play_history + WHERE user_id = $1 AND track_id = $2 AND completed = true + AND played_at > NOW() - ($3 || ' minutes')::interval + LIMIT 1`, + [userId, trackId, String(trackMin)] + ); + if (res.rows.length > 0) return true; + } + + const artistMin = ruleMap.get('artist') ?? 20; + if (artistId && artistMin > 0) { + const res = await this.db.pgClient.query( + `SELECT 1 FROM play_history ph + JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.artist_id = $2 AND ta.role = 'main' + WHERE ph.user_id = $1 AND ph.completed = true + AND ph.played_at > NOW() - ($3 || ' minutes')::interval + LIMIT 1`, + [userId, artistId, String(artistMin)] + ); + if (res.rows.length > 0) return true; + } + + return false; + } + + // --------------------------------------------------------------- + // D.8 — Multi-objective ranking + // --------------------------------------------------------------- + async rankCandidates( + candidates: Candidate[], + fatigue: FatigueState, + budgets: DiversityBudget[], + state: GeneratorContext['state'], + repetitionCheck: (trackId: string, artistId: string) => Promise<boolean> + ): Promise<Candidate[]> { + if (candidates.length === 0) return []; + + const trackIds = [...new Set(candidates.map(c => c.trackId))]; + const artistMap = new Map<string, string>(); + if (trackIds.length > 0) { + const artRes = await this.db.pgClient.query( + `SELECT DISTINCT ON (ta.track_id) ta.track_id, ta.artist_id + FROM track_artists_v2 ta + WHERE ta.track_id = ANY($1::uuid[]) AND ta.role = 'main'`, + [trackIds] + ); + for (const row of artRes.rows as { track_id: string; artist_id: string }[]) { + artistMap.set(row.track_id, row.artist_id); + } + } + + const genreMap = new Map<string, string>(); + if (trackIds.length > 0) { + const genreRes = await this.db.pgClient.query( + `SELECT DISTINCT ON (tg.track_id) tg.track_id, tg.genre_id + FROM track_genre tg + WHERE tg.track_id = ANY($1::uuid[]) + ORDER BY tg.track_id, tg.weight DESC`, + [trackIds] + ); + for (const row of genreRes.rows as { track_id: string; genre_id: string }[]) { + genreMap.set(row.track_id, row.genre_id); + } + } + + const artistBudget = budgets.find(b => b.dimension === 'artist'); + const currentEntropy = this.computeEntropy(candidates); + const targetEntropy = 0.55; + + const scored: { candidate: Candidate; score: number }[] = []; + for (const c of candidates) { + const artistId = artistMap.get(c.trackId) ?? ''; + const genreId = genreMap.get(c.trackId) ?? ''; + + const trackFatigue = fatigue.track.get(c.trackId) ?? 0; + const artistFatigue = fatigue.artist.get(artistId) ?? 0; + const genreFatigue = fatigue.genre.get(genreId) ?? 0; + const avgFatigue = (trackFatigue + artistFatigue + genreFatigue) / 3; + + const artistSpendRatio = artistBudget ? artistBudget.spent : 0; + const diversityBonus = 1 - artistSpendRatio; + const entropyBonus = 1 - Math.abs(currentEntropy - targetEntropy); + const wouldRepeat = await repetitionCheck(c.trackId, artistId); + + let score = W_ENJOY * c.relevance + - W_FATIGUE * avgFatigue + + W_DIVERSITY * diversityBonus + + W_ENTROPY * entropyBonus; + + if (wouldRepeat) { + score *= 0.1; + } + + scored.push({ candidate: c, score }); + } + + const entropyDrift = Math.abs(currentEntropy - targetEntropy); + if (entropyDrift > 0.2) { + const genCounts = new Map<string, number>(); + for (const s of scored) { + genCounts.set(s.candidate.generatorId, (genCounts.get(s.candidate.generatorId) ?? 0) + 1); + } + const maxCount = Math.max(...genCounts.values(), 1); + for (const s of scored) { + const genCount = genCounts.get(s.candidate.generatorId) ?? 0; + s.score += (1 - genCount / maxCount) * 0.15; + } + } + + scored.sort((a, b) => b.score - a.score); + return scored.map(s => s.candidate); + } + + // --------------------------------------------------------------- + // D.9 — Plan + replan loop + // --------------------------------------------------------------- + async buildPlan(userId: string, sessionId: string, seedTrackId?: string): Promise<Candidate[]> { + const allBeliefs = await this.db.getListenerBeliefs({ userId, limit: 200 }); + + // Fetch recent completed plays for anti-loop detection + const recentPlaysRes = await this.db.pgClient.query( + `SELECT t.id AS track_id, ta.artist_id, tg.genre_id, + af.bpm, af.energy, af.valence, af.instrumentalness, + tl.language, + t.release_date + FROM play_history ph + JOIN tracks t ON t.id = ph.track_id + LEFT JOIN track_audio_features af ON af.track_id = t.id + LEFT JOIN track_artists_v2 ta ON ta.track_id = t.id AND ta.role = 'main' + LEFT JOIN track_genre tg ON tg.track_id = t.id AND tg.weight = ( + SELECT MAX(weight) FROM track_genre WHERE track_id = t.id + ) + LEFT JOIN track_lyrics tl ON tl.track_id = t.id + WHERE ph.user_id = $1 AND ph.completed = true + ORDER BY ph.played_at DESC + LIMIT 20`, + [userId] + ); + const recentPlays: RecentPlay[] = recentPlaysRes.rows.map((r: any) => ({ + trackId: r.track_id, + artistId: r.artist_id ?? null, + genreId: r.genre_id ?? null, + bpm: r.bpm ?? null, + energy: r.energy ?? null, + language: r.language ?? null, + vocal: (r.instrumentalness == null) ? false : r.instrumentalness < 0.5, + decade: r.release_date ? Math.floor(new Date(r.release_date).getFullYear() / 10) * 10 : null, + valence: r.valence ?? null, + })); + + const state = await this.buildState(userId, sessionId); + const fatigue = await this.computeFatigue(userId); + const budgets = await this.getBudgets(userId); + + const arcType = this.pickArc(state); + const planSize = 20; + const slots = this.getArcSlots(arcType, planSize); + + let seedArtistId: string | null = null; + if (seedTrackId) { + seedArtistId = await this.resolveSeedArtistId(seedTrackId) ?? null; + } + + const recentExclusions: string[] = []; + const toleranceMap: Record<string, number> = {}; + const discoveryBeliefs = allBeliefs.filter(b => b.profile === 'discovery'); + for (const b of discoveryBeliefs) { + if (b.dimension) toleranceMap[b.dimension] = b.value; + } + + const ctx: GeneratorContext = { + userId, + seedTrackId: seedTrackId ?? null, + seedArtistId, + beliefs: allBeliefs, + recentExclusions, + toleranceMap, + state, + }; + + const allCandidates: Candidate[] = []; + for (const gen of ALL_GENERATORS) { + const result = await gen(this.db, ctx); + allCandidates.push(...result); + } + + if (allCandidates.length === 0) { + return []; + } + + const repetitionCheckFn = (tid: string, aid: string) => + this.checkRepetition(tid, aid, userId); + const ranked = await this.rankCandidates( + allCandidates, fatigue, budgets, state, repetitionCheckFn + ); + + const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays); + let forcedExperimental = false; + if (loopDim && ranked.length > 0) { + const expCtx: GeneratorContext = { + ...ctx, + recentExclusions: ctx.recentExclusions.slice(0, Math.min(ctx.recentExclusions.length, 50)), + }; + const extraCandidates: Candidate[] = []; + for (const gen of ALL_GENERATORS) { + const result = await gen(this.db, expCtx); + extraCandidates.push(...result); + } + const expRanked = await this.rankCandidates( + extraCandidates, fatigue, budgets, state, repetitionCheckFn + ); + const injected = expRanked.filter( + c => c.generatorId === 'experimental' || c.generatorId === 'discovery' + ); + ranked.unshift(...injected); + forcedExperimental = true; + } + + const seen = new Set<string>(); + const deduped: Candidate[] = []; + for (const c of ranked) { + if (!seen.has(c.trackId)) { + seen.add(c.trackId); + deduped.push(c); + } + } + + const plan: Candidate[] = []; + const usedTrackIds = new Set<string>(); + + if (!forcedExperimental) { + const unused = [...deduped]; + for (const slot of slots) { + const prefGenIds = this.roleToGeneratorIds(slot.role); + let idx = unused.findIndex( + c => prefGenIds.includes(c.generatorId) && !usedTrackIds.has(c.trackId) + ); + if (idx === -1) { + idx = unused.findIndex(c => !usedTrackIds.has(c.trackId)); + } + if (idx === -1) break; + const chosen = unused[idx]; + usedTrackIds.add(chosen.trackId); + plan.push(chosen); + unused.splice(idx, 1); + } + + if (plan.length < planSize) { + for (const c of deduped) { + if (plan.length >= planSize) break; + if (!usedTrackIds.has(c.trackId)) { + usedTrackIds.add(c.trackId); + plan.push(c); + } + } + } + } else { + for (const c of deduped) { + if (plan.length >= planSize) break; + plan.push(c); + } + } + + return plan.slice(0, planSize); + } + + async replan( + userId: string, + sessionId: string, + currentPlan: Candidate[], + playedTrackIds: string[], + seedTrackId?: string + ): Promise<Candidate[]> { + const remainingSlots = currentPlan.filter( + c => !playedTrackIds.includes(c.trackId) + ); + + if (remainingSlots.length >= 10 && currentPlan.length > 0) { + const fatigue = await this.computeFatigue(userId); + const budgets = await this.getBudgets(userId); + const state = await this.buildState(userId, sessionId); + + // Fetch recent plays for anti-loop + const recentPlaysRes = await this.db.pgClient.query( + `SELECT t.id AS track_id, ta.artist_id, tg.genre_id, + af.bpm, af.energy, af.valence, af.instrumentalness, + tl.language, + t.release_date + FROM play_history ph + JOIN tracks t ON t.id = ph.track_id + LEFT JOIN track_audio_features af ON af.track_id = t.id + LEFT JOIN track_artists_v2 ta ON ta.track_id = t.id AND ta.role = 'main' + LEFT JOIN track_genre tg ON tg.track_id = t.id AND tg.weight = ( + SELECT MAX(weight) FROM track_genre WHERE track_id = t.id + ) + LEFT JOIN track_lyrics tl ON tl.track_id = t.id + WHERE ph.user_id = $1 AND ph.completed = true + ORDER BY ph.played_at DESC + LIMIT 20`, + [userId] + ); + const recentPlays: RecentPlay[] = recentPlaysRes.rows.map((r: any) => ({ + trackId: r.track_id, + artistId: r.artist_id ?? null, + genreId: r.genre_id ?? null, + bpm: r.bpm ?? null, + energy: r.energy ?? null, + language: r.language ?? null, + vocal: (r.instrumentalness == null) ? false : r.instrumentalness < 0.5, + decade: r.release_date ? Math.floor(new Date(r.release_date).getFullYear() / 10) * 10 : null, + valence: r.valence ?? null, + })); + + const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays); + if (loopDim) { + return this.buildPlan(userId, sessionId, seedTrackId); + } + + const entropy = this.computeEntropy(currentPlan); + if (Math.abs(entropy - 0.55) > 0.2) { + return this.buildPlan(userId, sessionId, seedTrackId); + } + + return remainingSlots; + } + + return this.buildPlan(userId, sessionId, seedTrackId); + } + + // --------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------- + private async resolveSeedArtistId(seedTrackId: string): Promise<string | undefined> { + const res = await this.db.pgClient.query( + `SELECT ta.artist_id + FROM track_artists_v2 ta + WHERE ta.track_id = $1 AND ta.role = 'main' + LIMIT 1`, + [seedTrackId] + ); + if (res.rows[0]?.artist_id) return res.rows[0].artist_id as string; + + const fallback = await this.db.pgClient.query( + `SELECT al.artist_id + FROM tracks t + JOIN albums al ON al.id = t.album_id + WHERE t.id = $1`, + [seedTrackId] + ); + return fallback.rows[0]?.artist_id as string | undefined; + } +} diff --git a/backend/src/services/session-director.test.ts b/backend/src/services/session-director.test.ts new file mode 100644 index 0000000..d2c8897 --- /dev/null +++ b/backend/src/services/session-director.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from 'vitest'; +import { SessionDirector } from './session-director.service.js'; +import { DbService } from './db.service.js'; + +function makeMockDb(overrides: Record<string, any> = {}): DbService { + const mockQuery = vi.fn(); + return { + pgClient: { query: mockQuery }, + getListenerBeliefs: vi.fn().mockResolvedValue([]), + getLatestSessionState: vi.fn().mockResolvedValue(null), + seedDefaultDiversityBudgets: vi.fn().mockResolvedValue(undefined), + upsertDiversityBudget: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as DbService; +} + +describe('SessionDirector', () => { + describe('pickArc', () => { + const director = new SessionDirector(makeMockDb()); + + it('returns late-night for low energy', () => { + const arc = director.pickArc({ energy: 0.2, noveltyHunger: 0.3, sessionAgeMin: 10 } as any); + expect(arc).toBe('late-night'); + }); + + it('returns discovery for high energy + high novelty', () => { + const arc = director.pickArc({ energy: 0.7, noveltyHunger: 0.6, sessionAgeMin: 5 } as any); + expect(arc).toBe('discovery'); + }); + + it('returns energetic for high energy + low novelty', () => { + const arc = director.pickArc({ energy: 0.7, noveltyHunger: 0.3, sessionAgeMin: 5 } as any); + expect(arc).toBe('energetic'); + }); + + it('returns comfort for medium energy', () => { + const arc = director.pickArc({ energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10 } as any); + expect(arc).toBe('comfort'); + }); + }); + + describe('getArcSlots', () => { + const director = new SessionDirector(makeMockDb()); + + it('returns correct slot count', () => { + expect(director.getArcSlots('comfort', 20).length).toBe(20); + expect(director.getArcSlots('discovery', 20).length).toBe(20); + expect(director.getArcSlots('energetic', 10).length).toBe(10); + expect(director.getArcSlots('late-night', 8).length).toBe(8); + }); + + it('has valid role names', () => { + const slots = director.getArcSlots('comfort', 20); + const validRoles = ['known', 'adjacent', 'favorite', 'similar', 'new', 'medium', 'high', 'peak', 'cooldown', 'soft', 'ambient', 'acoustic', 'slow']; + slots.forEach(s => expect(validRoles).toContain(s.role)); + }); + }); + + describe('computeEntropy', () => { + const director = new SessionDirector(makeMockDb()); + + it('returns 0 for empty set', () => { + expect(director.computeEntropy([])).toBe(0); + }); + + it('returns 1 for all-same-artist', () => { + const candidates = [ + { trackId: 't1', generatorId: 'c', relevance: 1, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't1', fusedValue: 1 }] }, + { trackId: 't2', generatorId: 'c', relevance: 1, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't2', fusedValue: 1 }] }, + ] as any; + expect(director.computeEntropy(candidates)).toBe(1); + }); + + it('returns ~0.5 for two-artist split', () => { + const candidates = [ + { trackId: 't1', generatorId: 'c', relevance: 1, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't1', fusedValue: 1 }] }, + { trackId: 't2', generatorId: 'c', relevance: 1, explanation: [{ subjectType: 'artist', subjectId: 'a2', predicate: 'credited_main_on', objectType: 'track', objectId: 't2', fusedValue: 1 }] }, + ] as any; + const hhi = director.computeEntropy(candidates); + expect(hhi).toBeCloseTo(0.5); + }); + }); + + describe('rankCandidates', () => { + it('sorts candidates by score descending', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ rows: [] }); + const director = new SessionDirector(db); + + const candidates = [ + { trackId: 't1', generatorId: 'a', relevance: 0.9, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't1', fusedValue: 1 }] }, + { trackId: 't2', generatorId: 'b', relevance: 0.3, explanation: [{ subjectType: 'artist', subjectId: 'a2', predicate: 'credited_main_on', objectType: 'track', objectId: 't2', fusedValue: 1 }] }, + ]; + const fatigue = { artist: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 }; + const budgets = [{ dimension: 'artist', budgetShare: 0.2, horizonMin: 30, spent: 0 }]; + const state = { energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10, lastArtistIds: [], lastGenreIds: [], context: null }; + + const ranked = await director.rankCandidates(candidates, fatigue, budgets, state, async () => false); + expect(ranked[0].relevance).toBeGreaterThanOrEqual(ranked[ranked.length - 1].relevance); + }); + }); + + describe('buildState', () => { + it('returns state with default values when no prior session', async () => { + const db = makeMockDb(); + (db.pgClient.query as any).mockResolvedValue({ rows: [] }); + (db.getListenerBeliefs as any).mockResolvedValue([]); + (db.getLatestSessionState as any).mockResolvedValue(null); + const director = new SessionDirector(db); + const state = await director.buildState('user-1'); + expect(state).toHaveProperty('energy'); + expect(state).toHaveProperty('noveltyHunger'); + expect(state).toHaveProperty('sessionAgeMin'); + expect(typeof state.energy).toBe('number'); + }); + }); +}); diff --git a/backend/src/types/job.types.ts b/backend/src/types/job.types.ts new file mode 100644 index 0000000..3b1d2f9 --- /dev/null +++ b/backend/src/types/job.types.ts @@ -0,0 +1,27 @@ +export interface MetadataRefreshJob { + trackId: string; + refreshType: 'full' | 'partial'; +} + +export interface AudioAnalysisJob { + trackId: string; + features: string[]; +} + +export interface CleanupJob { + reason: 'expired' | 'manual'; + targetFiles: string[]; +} + +export interface LibraryScanJob { + directory: string; +} + +export interface ReindexTracksJob {} + +export interface ReprocessArtistsJob { + batchSize?: number; + offset?: number; +} + +export type JobPayload = MetadataRefreshJob | AudioAnalysisJob | CleanupJob | LibraryScanJob | ReindexTracksJob | ReprocessArtistsJob; diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..225d137 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "node", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "*": ["node_modules/*"] + }, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts new file mode 100644 index 0000000..7dd1325 --- /dev/null +++ b/backend/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b933b4c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,68 @@ +services: + db: + image: postgres:16 + restart: always + environment: + POSTGRES_USER: user + POSTGRES_PASSWORD: password + POSTGRES_DB: muzick + ports: + - "5432:5432" + volumes: + - ./data/postgres:/var/lib/postgresql/data + - ./backend/src/db/schema.sql:/docker-entrypoint-initdb.d/schema.sql + + redis: + image: redis:7 + restart: always + ports: + - "6379:6379" + volumes: + - ./data/redis:/data + + search: + image: typesense/typesense:0.25.1 + restart: always + ports: + - "8108:8108" + volumes: + - ./data/typesense:/data + command: --data-dir /data --api-key=muzick-key + + backend: + build: ./backend + ports: + - "3000:3000" + environment: + DATABASE_URL: postgresql://user:password@db:5432/muzick + REDIS_URL: redis://redis:6379 + TYPESENSE_API_KEY: muzick-key + MUSIC_DIR: /music + volumes: + - /mnt/hdd1/media/Music:/music:ro + depends_on: + - db + - redis + - search + + frontend: + build: ./frontend + ports: + - "5174:80" + depends_on: + - backend + + worker: + build: ./workers + network_mode: host + environment: + DATABASE_URL: postgresql://user:password@127.0.0.1:5432/muzick + REDIS_URL: redis://127.0.0.1:6379 + MUSICBRAINZ_CONTACT: ${MUSICBRAINZ_CONTACT} + LASTFM_API_KEY: ${LASTFM_API_KEY} + LASTFM_SHARED_SECRET: ${LASTFM_SHARED_SECRET} + DISCOGS_TOKEN: ${DISCOGS_TOKEN} + SOCKS_PROXY_URL: ${SOCKS_PROXY_URL} + MUSIC_DIR: /music + volumes: + - /mnt/hdd1/media/Music:/music:ro diff --git a/docs/architecture/01-system-overview.md b/docs/architecture/01-system-overview.md new file mode 100644 index 0000000..fd1f819 --- /dev/null +++ b/docs/architecture/01-system-overview.md @@ -0,0 +1,52 @@ +# System Overview + +## High-Level Architecture + +`muzick` follows a distributed architecture centered around a shared PostgreSQL database and a distributed task queue (BullMQ). + +```mermaid +graph TD + User((User)) --> Web[Frontend - React/Vite] + Web --> API[Backend - Fastify] + API --> DB[(PostgreSQL)] + API --> Search[Typesense] + API --> Cache[(Redis)] + API --> Queue[BullMQ] + + subgraph Workers + W1[Metadata Worker] + W2[Essentia Audio Worker] + W3[Cleanup/Sweep Worker] + end + + Queue --> W1 + Queue --> W2 + Queue --> W3 + + W1 --> DB + W2 --> DB + W3 --> DB + W1 --> External[External APIs: MusicBrainz/Discogs] + W2 --> Audio[/Filesystem/Music] +``` + +## Component Roles + +### **1. Frontend (The Interface)** +- **React/Vite:** High-performance UI. +- **TanStack Router/Query:** Handles complex navigation and provides the **Look-ahead Buffer** for the continuous playback stream. +- **Zustand:** Manages the active "Vibe" session state and local playback queue. + +### **2. Backend (The Brain)** +- **Fastify:** High-throughput API server. +- **Business Logic:** Manages the **Rolling Window** recommendation algorithm, the **Dislike State Machine**, and the **Session Management**. +- **Typesense:** Provides ultra-fast fuzzy search across the entire library. + +### **3. Workers (The Muscle)** +- **Metadata Worker:** Orchestrates enrichment via MusicBrainz, Discogs, and LRCLib. +- **Essentia Worker:** Performs heavy CPU-bound audio feature extraction (BPM, Key, etc.). +- **Sweep Worker:** Handles periodic cleanup (Dislike $\rightarrow$ Deletion) and filesystem-to-DB reconciliation. + +### **4. Data Layer** +- **PostgreSQL:** The ultimate source of truth for metadata, user preferences, and session history. +- **Redis:** Powers the task queue (BullMQ) and provides ephemeral session data. diff --git a/docs/architecture/02-invariants-and-risks.md b/docs/architecture/02-invariants-and-risks.md new file mode 100644 index 0000000..6add01d --- /dev/null +++ b/docs/architecture/02-invariants-and-risks.md @@ -0,0 +1,37 @@ +# Invariants and Risks + +This document outlines the critical rules that MUST be respected to maintain system integrity and the identified technical risks. + +## 1. System Invariants (The "Never Break" Rules) + +### **A. Data Consistency (The "No Ghost Tracks" Rule)** +* **Invariant:** Every `track` record in the database must correspond to a physical file on the disk. +* **Mechanism:** The **Consistency Worker** must run periodically to reconcile the database with the `/mnt/hdd1/media/Music` directory. Any discrepancy must result in the track being marked as `MISSING` in the DB, rather than deleted immediately. + +### **B. Session Integrity (The "No Deadlocks" Rule)** +* **Invariant:** An `ACTIVE` recommendation batch must eventually reach a terminal state (`RESOLVED` or `FAILED`). +* **Mechanism:** Every batch must have a `last_interaction_at` timestamp. A background sweep must transition stale `ACTIVE` sessions to `RESOLVED` to allow new sessions to start. + +### **C. Filesystem Safety (The "Irreversible Action" Rule)** +* **Invariant:** Hard deletion of a file from the filesystem is the final, irreversible step in the `PENDING_REMOVAL` lifecycle. +* **Mechanism:** A track only enters the `DELETE_FILE` state after it has been `warned` for at least 24 hours and the user has not explicitly triggered a `RESTORE`. + +## 2. Known Technical Risks + +### **A. The "Similarity Explosion" (Scalability)** +* **Risk:** Precomputing a $O(n^2)$ similarity matrix for large libraries will exhaust database resources. +* **Mitigation:** + * Use **Tiered Similarity**: Metadata-based matches (Instant) $\rightarrow$ Audio-feature matches (Asynchronous/On-demand). + * Limit similarity computation to tracks within the same genre or recent listening window. + +### **B. Computational Exhaustion (Resource Management)** +* **Risk:** Heavy audio analysis (Essentia) can starve the API of CPU/RAM. +* **Mitigation:** Audio analysis and metadata enrichment must run in dedicated worker processes (containers) with strict resource limits (cgroups/Docker). + +### **C. Metadata Drift** +* **Risk:** External providers (MusicBrainz/Discogs) may provide conflicting or low-quality data. +* **Mitigation:** Implement a priority-based enrichment pipeline and allow manual user overrides via the UI. + +### **D. Race Conditions (The "Cleanup Race")** +* **Risk:** A user interacts with a track at the exact moment the Sweep Worker attempts to delete the file. +* **Mitigation:** Use transactional state transitions (e.g., `UPDATE tracks SET state = 'HIDDEN' WHERE id = X AND state = 'PENDING_REMOVAL'`) to ensure an action only happens if the state hasn't changed. diff --git a/docs/architecture/03-backend-spec.md b/docs/architecture/03-backend-spec.md new file mode 100644 index 0000000..dd77566 --- /dev/null +++ b/docs/architecture/03-backend-spec.md @@ -0,0 +1,53 @@ +# Backend Specification + +## Core Technology Stack +- **Runtime:** Node.js (TypeScript) +- **Framework:** Fastify +- **Communication:** REST API (primary) +- **Queueing:** BullMQ with Redis + +## 1. API Architecture + +### **Library Management** +- `GET /api/tracks`: Paginated list of tracks (supports sort/filter). +- `GET /api/tracks/{id}`: Full track metadata. +- `GET /api/search?q={query}`: Fuzzy search via Typesense. +- `POST /api/library/reindex`: Manual trigger for the indexing worker. + +### **The Vibe Engine (Recommendation)** +- `GET /api/vibe`: Returns a **Sequence Chunk** of track IDs. + - *Client Implementation:* Uses TanStack Query to implement a "Look-ahead Buffer." +- `GET /api/vibe/from-genre?genre={id}`: Generates a session based on a specific genre. + +### **The Lifecycle (Dislike/Removal)** +- `POST /api/dislike/{track_id}`: Moves track to `PENDING_REMOVAL` (State: `HIDDEN`). +- `DELETE /api/dislike/{id}`: Restores track to `LIBRARY`. +- `GET /api/dislikes`: Lists all tracks in the quarantine. +- `POST /api/dislikes/sweep`: Manual trigger for the background cleanup worker. + +### **Session Management** +- `GET /api/sessions/current`: Returns the metadata for the current `ACTIVE` recommendation batch. +- `POST /api/sessions/heartbeat`: Updates `last_interaction_at` for the active batch. + +## 2. The "Rolling Window" Algorithm + +To provide an infinite, evolving stream, the backend implements a **Stateful Sequence Generator**. + +### **Algorithm Steps:** +1. **Seed Selection:** Identify the `center_track_id` (the last successfully played track). +2. **Candidate Generation:** + - **Primary (80%):** Fetch tracks similar to the `center_track_id` (Metadata + Audio Feature match). + - **Discovery (20%):** Fetch tracks from the **Probation Pool** (newly acquired recommendations). +3. **Sequence Construction:** + - Group results into a "Chunk" (e.g., 20 tracks). + - Apply **Batch Rules**: + - Max 1 song per artist in a single chunk. + - Max 2 songs per genre in a single chunk. + - Mix in "probation" tracks at a controlled rate. +4. **Response:** Return a JSON array of track objects with pre-calculated sequence order. + +## 3. Business Logic Invariants + +- **The "No Ghost" Rule:** The backend MUST verify file existence before returning a track in a `Vibe` sequence. +- **The "Success-Driven Center" Rule:** The `center_track_id` is updated ONLY when a track's `completed` flag is set to `true` via `/api/history`. +- **The "Atomicity" Rule:** All state transitions (e.g., `PENDING_REMOVAL` $\rightarrow$ `DELETED`) must be handled within a database transaction. diff --git a/docs/architecture/04-frontend-spec.md b/docs/architecture/04-frontend-spec.md new file mode 100644 index 0000000..46c6139 --- /dev/null +++ b/docs/architecture/04-frontend-spec.md @@ -0,0 +1,45 @@ +# Frontend Specification + +## Core Technology Stack +- **Framework:** React (Vite-based) +- **Routing:** TanStack Router (Type-safe routing) +- **Data Fetching:** TanStack Query (Managing server state & caching) +- **State Management:** Zustand (Managing local client-side playback and vibe state) +- **Styling:** CSS Variables (Enabling easy theme switching) + +## 1. Key UI Patterns + +### **The "Look-ahead Buffer" (Seamless Playback)** +To prevent playback gaps, the frontend implements a **Prefetching Queue**. +- **Mechanism:** The client maintains a `playback_buffer` in Zustand containing the next 20 tracks. +- **Implementation:** When the user reaches track $N$, TanStack Query triggers a fetch for the next chunk ($N+20$) in the background. +- **User Experience:** Tapping "next" is near-instantaneous as the track is already in memory. + +### **The "Vibe" Interface** +The core feature is the **Active Vibe Page**. +- **Visuals:** Shows the current "Rolling Window" as a progress bar/timeline. +- **Real-time Updates:** Displays "Incoming Recommendations" (Probation tracks) as they are discovered. +- **Controls:** Quick-access "Keep" / "Dislike" buttons that trigger the lifecycle state machine. + +### **The "Quarantine" Page** +A management view for the `PENDING_REMOVAL` state. +- **Features:** List of hidden tracks, "time remaining" timers, and "Restore" / "Delete" actions. + +## 2. State Management Strategy + +### **Zustand Stores** +- **`usePlaybackStore`:** Tracks `current_track`, `playback_position`, `is_playing`, and the `local_queue`. +- **`useVibeStore`:** Manages the `active_session_id` and the current "Center Track" context. + +### **TanStack Query** +- Used for all standard CRUD operations (Library, Artists, Albums). +- Configured with aggressive `staleTime` for static data (Artists/Albums) and low `staleTime` for dynamic data (History/Stats). + +## 3. Navigation Structure + +- **Home:** Continue Listening, Recently Added, Recently Played. +- **Library:** Artists, Albums, Tracks, Genres (hierarchical). +- **Vibe:** The infinite player/discovery interface. +- **Discover:** Manual exploration of similar artists/tracks. +- **Search:** Global fuzzy search. +- **Settings:** Theme, playback modes, and automation rules. diff --git a/docs/architecture/05-recommendation-spec.md b/docs/architecture/05-recommendation-spec.md new file mode 100644 index 0000000..eb6cd0c --- /dev/null +++ b/docs/architecture/05-recommendation-spec.md @@ -0,0 +1,60 @@ +# Recommendation & Vibe Specification + +This document defines the logic for the "Vibe" engine, moving from simple similarity to a continuous, evolving listening experience. + +## 1. The "Vibe" Concept +The "Vibe" is an infinite, stateful listening session. Unlike a static playlist, it is a **Rolling Window** that evolves based on user interaction. + +## 2. Scoring Logic + +When generating a sequence, every candidate track is assigned a score. + +$$Score = (W_{genre} \cdot S_{genre}) + (W_{artist} \cdot S_{artist}) + (W_{random} \cdot R)$$ + +### **Scoring Components** +* **$S_{genre}$ (Genre Match):** + * Uses hierarchical weights (e.g., `Deep House` (1.0) $\rightarrow$ `House` (0.8) $\rightarrow$ `Electronic` (0.5)). + * Calculated as the highest weight match between candidate and "Center Track." +* **$S_{artist}$ (Artist Similarity):** + * A binary or weighted score based on whether the artist is in the user's "Liked" list or frequent listening history. +* **$R$ (Randomness/Exploration):** + * A jitter factor to ensure the queue doesn't feel repetitive. + +## 3. The Rolling Window Algorithm + +The backend does not return a fixed list. It returns a **Sequence Chunk**. + +### **Step-by-Step Generation** +1. **Identify the Center:** Find the `last_successfully_played_track_id`. +2. **Candidate Selection:** + * **Local Pool (80%):** Tracks from the user's library similar to the center track. + * **Probation Pool (20%):** Tracks from the `recommendation_batch` that have `probation=1`. +3. **Constraint Application (Batch Rules):** + * **Diversity Check:** No more than 1 track from the same artist per chunk. + * **Genre Cap:** No more than 2 tracks from the same genre per chunk. +4. **Chunking:** Return a sequence of $N$ tracks (e.g., 20). + +## 4. The "Vibe" Session Lifecycle + +A "Vibe" is represented by a `recommendation_batch` record. + +| State | Description | +| :--- | :--- | +| **ACTIVE** | The user is currently listening. The stream is being generated. | +| **RESOLVED** | The session has ended naturally or timed out. | +| **FAILED** | The session was interrupted by a critical error or manual reset. | + +### **Transition Logic** +* **Start:** User clicks "Start Vibe" $\rightarrow$ Create `ACTIVE` batch. +* **Progress:** User listens $\rightarrow$ Update `last_interaction_at` in the batch. +* **Termination:** + * **Natural:** User exits the app $\rightarrow$ Session marked `RESOLVED` after 24h. + * **Manual:** User ends session $\rightarrow$ Mark `RESOLVED` immediately. + * **Timeout:** No interaction for 24h $\rightarrow$ Mark `RESOLVED`. + +## 5. Success/Failure Feedback Loop + +The engine learns from the `feedback` table: +* **`action = 'promoted'`:** (High Score) Increase weight of this genre/artist in future seeds. +* **`action = 'disliked'`:** (Negative Signal) Decrease weight of this genre/artist. +* **`action = 'skipped'`:** (Transient Negative) Do not adjust long-term weights, but avoid this specific track in the current session window. diff --git a/docs/architecture/06-lifecycle-spec.md b/docs/architecture/06-lifecycle-spec.md new file mode 100644 index 0000000..ece80c2 --- /dev/null +++ b/docs/architecture/06-lifecycle-spec.md @@ -0,0 +1,53 @@ +# Lifecycle & Removal Specification + +This document defines the "Dislike $\rightarrow$ Delayed Deletion" lifecycle. This state machine ensures user intent is respected while preventing accidental permanent loss of music. + +## 1. The Dislike State Machine + +A track follows this state transition to ensure a "Grace Period" before physical deletion. + +| Current State | Action | Next State | Side Effects | +| :--- | :--- | :--- | :--- | +| **LIBRARY** | User Dislikes | **PENDING_REMOVAL** | `dislikes` row created; Track becomes `HIDDEN`. | +| **PENDING_REMOVAL** | User Restores | **LIBRARY** | `dislikes` row deleted; Track becomes `VISIBLE`. | +| **PENDING_REMOVAL** | Grace Period Ends | **WARNING_SENT** | `ntfy` notification sent; `warned_at` timestamp set. | +| **WARNING_SENT** | 24h Passes | **DELETED** | File deleted from FS; Track record removed from DB. | + +## 2. Detailed Transitions + +### **Phase 1: The Dislike (Immediate)** +When a user triggers a dislike: +1. **DB Transaction:** + * Update `tracks.state = 'HIDDEN'`. + * Insert into `dislikes` table `{track_id, disliked_at: now}`. + * Log `feedback(action='disliked')`. +2. **UI Update:** The track immediately disappears from all "active" views (Library, Playlists, Vibe Queue, Search). + +### **Phase 2: The Grace Period (The "Safety Net")** +* **Duration ($X$):** Configurable (default: 48 hours). +* **Behavior:** The track remains on disk and in the database, but is filtered out of all user-facing discovery and playback. + +### **Phase 3: The Warning (The "Nudge")** +When `now > disliked_at + X`: +1. **Worker Action:** The `Cleanup/Sweep Worker` identifies the track. +2. **Notification:** Sends a message via `ntfy` (e.g., *"Are you sure? '<Track Name>' is scheduled for deletion in 24h"*). +3. **DB Update:** Set `dislikes.warned_at = now`. + +### **Phase 4: The Finality (The "Cleanup")** +When `now > warned_at + 24h`: +1. **FS Action:** Delete the physical file at `tracks.path`. +2. **DB Action:** + * Cascade delete all related records (history, play_counts, etc.). + * Remove the `tracks` record. +3. **Logging:** Log `feedback(action='deleted_permanent')`. + +## 2. Safety Invariants + +- **No Immediate Deletion:** No user action (other than a "Hard Delete" admin command) can trigger immediate file deletion. +- **State Consistency:** A track cannot be in `PENDING_REMOVAL` and `LIBRARY` simultaneously. +- **Atomic Deletion:** The file deletion and the database removal must be treated as a single logical unit of work to prevent "Orphaned Files" (files on disk with no DB record) or "Ghost Records" (DB records with no file). + +## 3. User Recovery +The "Restore" action is a simple reversal: +- `DELETE FROM dislikes WHERE track_id = X;` +- `UPDATE tracks SET state = 'LIBRARY' WHERE id = X;` diff --git a/docs/architecture/07-worker-spec.md b/docs/architecture/07-worker-spec.md new file mode 100644 index 0000000..d3a86aa --- /dev/null +++ b/docs/architecture/07-worker-spec.md @@ -0,0 +1,57 @@ +# Worker & Job Specification + +This document defines the background processing architecture using **BullMQ**. Workers are responsible for CPU-intensive tasks and time-sensitive cleanup. + +## 1. Job Architecture + +All jobs are dispatched via the Backend API and processed by specialized Worker containers. + +| Job Name | Priority | Responsibility | Trigger | +| :--- | :--- | :--- | :--- | +| `metadata_refresh` | Medium | Re-scanning files for tag/metadata changes. | Manual (`POST /api/library/reindex`) | +| `artwork_download` | Low | Fetching covers from Cover Art Archive/Discogs. | On metadata enrichment or new file. | +| `lyrics_download` | Low | Fetching synced lyrics (LRCLib). | On metadata enrichment. | +| `recommendation_gen` | Low | Calculating new "Vibe" seeds and batching. | On session start or after playback completion. | +| `audio_analysis` | High | Running `essentia` for BPM, Key, Energy. | New file/re-index. | +| `filesystem_rescan` | High | Reconciling DB with actual disk state. | Scheduled (Daily/Weekly). | +| `cleanup_sweep` | Medium | Managing the Dislike Lifecycle/Deletion. | Scheduled (Hourly). | + +## 2. Detailed Job Workflows + +### **A. Audio Analysis Pipeline (`audio_analysis`)** +This is the most resource-intensive job. +1. **Input:** `track_id`. +2. **Process:** + * Spin up `essentia` subprocess. + * Extract BPM, Key, Energy, and Melodic features. +3. **Output:** Update `track_audio_features` table and mark `audio_features_ready = true`. + +### **B. Metadata Enrichment Pipeline (`metadata_refresh` / `artwork_download`)** +Triggered when a new file is detected or a re-index occurs. +1. **Input:** `track_id`. +2. **Process:** + * Lookup `mbid` via MusicBrainz. + * Fetch lyrics via LRCLib. + * Fetch artwork via Cover Art Archive. +3. **Output:** Update `tracks`, `artists`, and `albums` tables. + +### **C. The "Consistency" Worker (`filesystem_rescan`)** +Ensures the database is an accurate reflection of the disk. +1. **Process:** + * Walk through `/mnt/hdd1/media/Music`. + * Compare `mtime` and `size` against DB. + * **Action:** If a file is missing, set `track.state = 'MISSING'`. If a new file is found, trigger `metadata_refresh`. + +### **D. The "Cleanup" Worker (`cleanup_sweep`)** +Handles the temporal logic of the dislike lifecycle. +1. **Process:** + * Check `dislikes` where `state = 'warned'` and `warned_at < now - 24h`. + * Trigger physical file deletion and DB row removal. + * Check `dislikes` where `state = 'hidden'` and `disliked_at < now - 48h`. + * Trigger `ntfy` notification. + +## 3. Error Handling & Retries + +- **Exponential Backoff:** All external API jobs (MusicBrainz, etc.) must use exponential backoff to respect rate limits. +- **Dead Letter Queue (DLQ):** Jobs that fail after 5 retries are moved to a DLQ for manual inspection via the Admin Dashboard. +- **Idempotency:** All jobs must be idempotent. Running `audio_analysis` twice on the same `track_id` must not create duplicate data or errors. diff --git a/docs/architecture/08-data-model.md b/docs/architecture/08-data-model.md new file mode 100644 index 0000000..d90700e --- /dev/null +++ b/docs/architecture/08-data-model.md @@ -0,0 +1,63 @@ +# Data Model Specification + +This document defines the data schema across PostgreSQL (Primary), Typesense (Search), and Redis (Caching/Queueing). + +## 1. Relational Schema (PostgreSQL) + +### **Core Tables** + +#### `tracks` +* `id` (UUID, PK) +* `path` (TEXT, UNIQUE) - Physical disk path. +* `hash` (TEXT, INDEX) - BLOB/MD5 hash for deduplication. +* `title`, `artist`, `album` (TEXT) +* `duration` (REAL) - In seconds. +* `state` (ENUM) - `[LIBRARY, RECOMMENDED, HIDDEN, MISSING, DELETED]` +* `play_count`, `skip_count`, `dislike_count` (INTEGER) +* `last_played_at` (TIMESTAMP) +* `mtime` (REAL) - File mtime at last index. +* `source_type` (ENUM) - `[MANUAL, RECOMMENDATION]` + +#### `artists` & `albums` +* `artists`: `id`, `name`, `mbid`, `discogs_id`, `image_path`. +* `albums`: `id`, `artist_id`, `title`, `year`, `artwork_id`. + +#### `genre` & `track_genre` +* `genre`: `id`, `name`, `parent_id` (Self-join for hierarchy). +* `track_genre`: `track_id`, `genre_id`, `weight` (Decimal). + +### **Recommendation & Lifecycle Tables** + +#### `recommendation_batch` +* `id` (UUID, PK) +* `user_id` (UUID) +* `status` (ENUM) - `[ACTIVE, RESOLVED, FAILED]` +* `last_interaction_at` (TIMESTAMP) +* `seed_track_id` (UUID, FK) + +#### `dislikes` +* `track_id` (UUID, FK) +* `disliked_at` (TIMESTAMP) +* `warned_at` (TIMESTAMP, NULLABLE) +* `state` (ENUM) - `[HIDDEN, WARNED, DELETED]` + +### **Metadata & Enrichment** +* `track_audio_features`: `track_id`, `bpm`, `key`, `energy`, `danceability`, etc. +* `track_lyrics`: `track_id`, `lyrics_text`, `provider`. +* `mb_cache` / `lastfm_cache`: Key-Value stores for external API responses. + +## 2. Search Schema (Typesense) + +Typesense is used for ultra-fast, fuzzy search. Indices are rebuilt from PostgreSQL. + +**Index: `tracks`** +* `title` (string, facet) +* `artist` (string, facet) +* `album` (string, facet) +* `genres` (string, facet) +* `state` (string, filterable) + +## 3. Cache & Queue (Redis) + +* **BullMQ:** Stores job payloads and processing states. +* **Session Cache:** Stores ephemeral playback metadata and current "rolling window" track IDs. diff --git a/docs/architecture/09-recommendation-and-identity-v2.md b/docs/architecture/09-recommendation-and-identity-v2.md new file mode 100644 index 0000000..f753619 --- /dev/null +++ b/docs/architecture/09-recommendation-and-identity-v2.md @@ -0,0 +1,1182 @@ +# Recommendation & Identity v2 + +This document retires the v1 recommendation engine; it does not tune it. +Where the old `05-recommendation-spec.md` shipped a single CTE inside +`db.service.ts:getNextVibeChunk` (line ~715) that simultaneously did +candidate generation, scoring, evaluation, exploration policy, and +session composition, v2 splits that loading into five cooperating +systems. The CTE stays untouched (and buggy) until System D lands, +then is deleted. + +The two philosophy docs — *Music Intelligence System* and *Discovery +Pipeline / Session Director* — describe the shape. This doc is the +engineering plan: schema, write paths, read paths, acceptance, and an +explicit *replaces* list per system. + +### What survives from the old v2 plan + +- **Phase 4 — Image candidates** (`image_candidates` table). Preserved + verbatim in §F below. Orthogonal to recommendation; the bad-image + problem is a provenance problem, unrelated to the engine. +- **§1.1's intent** (the engine must learn from plays, not from a Keep + button the user does not press) — re-implemented under System B as + evidence rows feeding per-profile beliefs, not as a + `feedback(action='promoted')` row. + +### What is retired by this plan + +The old v2 **Phases 1, 2, 3, 5** are subsumed and displaced: + +| Old phase | Retired by | Why | +|---|---|---| +| Phase 1 (engine tuning: recency, overplay, same-art, cap) | System D | All four are symptoms of doing the session director's job inside a scorer. In D they stop being tuning constants and become structural consequences of fatigue + budgets. | +| Phase 1 §1.1 (implicit promote) | System B | `feedback(action='promoted')` is the wrong shape; evidence rows under per-profile beliefs are the right shape. | +| Phase 2 (`album_artists` junction) | System A | A single probabilistic claims graph subsumes album ownership, MB credit, and identity groups as predicate triples. No separate `album_artists` table. | +| Phase 3 (MB authoritative credit, re-credit pass) | System A | MB is the structural *spine* (it seeds high-trust claims), not the *truth*. Re-credit pass becomes "fetch MB claims into the graph"; no destructive overwrite of `track_artists`. | +| Phase 5 (`artist_groups`) | System A | `alias_of` is a continuous belief (`P(DOOM ≡ Madvillain)`), not a curated flag table. | + +### Architectural principles + +1. **Music is a graph of entities + probabilistic claims**, not a + folder of files or a table of flat similarity rows. +2. **Truth is probabilistic fusion.** Every claim is evidence, not + fact. Conflicts coexist; resolution happens at read time, weighted by + source trust and recency. +3. **MusicBrainz is the structural spine** (MBIDs + credit bands as + high-trust claim seeds), never the truth by decree. When MB and tags + disagree, both claims live in the graph with different trust weights. +4. **Aliasing is continuous** and evolves with listening behavior. A + `alias_of` claim is a belief with a confidence value, reinforced + when the listener plays both aliases back-to-back in a session. +5. **Listener identity is multidimensional**, keyed on `user_id` from + the start (single user today, multi-user tomorrow — no retrofit). + Multiple profiles coexist: long-term, current obsession, discovery, + negative, forgotten, contextual. +6. **Sessions are directed, not scored.** The objective is the best + next *hour*, not the best next *track*. Fatigue, diversity budgets, + arcs, surprise, callbacks, and an entropy target all live in a + planner that re-plans continuously. +7. **Discovery is autonomous and separate from playback.** Acquisition + writes candidates into the graph; probation is a state on those + candidates; the session director consumes probation-tracked tracks + without knowing they are probation. +8. **Everything decays unless reinforced.** Beliefs, claims' confidence, + and fatigue all weaken over time. This prevents permanent historical + bias and keeps the system learning the *current* listener, not the + listener of two years ago. + +--- + +## The five systems + +``` + A Knowledge graph (probabilistic fusion) + │ + ┌────┴────┐ + B E + Listener Acquisition + model pipeline + │ + C Candidate generators + │ + D Session director +``` + +- **A** is the foundation; no other system can run without it. +- **B** and **E** depend only on A and may be built in parallel. +- **C** depends on A (graph traversal) and reads B (profiles inform + generator selection, e.g. revival generator reads the forgotten + profile, discovery generator reads the discovery profile). +- **D** depends on C (needs generators to populate the plan) and B + (needs listener state from beliefs); it is the final piece. +- **Phase 4 (image candidates)** ships any time, independent of A–E. + +Critical path: **A → {B, E} → C → D**. + +--- + +## System A — Knowledge graph (probabilistic fusion) + +Maintains the connected entity graph with probabilistic relationships. +Never contains user-specific knowledge (objective claims carry +`user_id = NULL`); listener-behavior-derived claims carry `user_id` +and fuse into the per-user graph view at read time. + +### A.1 Schema + +```sql +-- Source trust weights. One row per source of claims. Tunable. +CREATE TABLE source_trust ( + key TEXT PRIMARY KEY, -- 'mb' | 'discogs' | 'lastfm' | 'tag' | 'listener_behavior' | 'curated' + trust REAL NOT NULL CHECK (trust >= 0 AND trust <= 1.0), + description TEXT NOT NULL +); + +-- Claims: the spine of the graph. One row per (subject, predicate, object, source). +-- user_id is NULL for objective claims (MB, Discogs, tags), non-NULL for +-- listener-behavior-derived claims (e.g. weak alias_of from adjacent plays). +CREATE TABLE claims ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID, -- NULL = objective + subject_type TEXT NOT NULL, -- 'artist'|'album'|'track'|'label'|'genre'|'scene' + subject_id UUID NOT NULL, + predicate TEXT NOT NULL, -- see A.2 + object_type TEXT NOT NULL, + object_id UUID NOT NULL, + source TEXT NOT NULL REFERENCES source_trust(key), + confidence REAL NOT NULL DEFAULT 1.0 CHECK (confidence >= 0 AND confidence <= 1.0), + evidence_at TIMESTAMPTZ NOT NULL, -- when the source asserted this + last_reinforced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + raw JSONB, -- original payload for audit + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (subject_type, subject_id, predicate, object_type, object_id, source, user_id) +); +CREATE INDEX claims_subject_idx ON claims (subject_type, subject_id, predicate); +CREATE INDEX claims_object_idx ON claims (object_type, object_id, predicate); +CREATE INDEX claims_user_idx ON claims (user_id) WHERE user_id IS NOT NULL; + +-- Every entity that participates in the graph carries an optional MBID as +-- the structural spine anchor. These columns already exist on artists/albums +-- today; we add to tracks. MBID presence raises the entity's identity +-- resolution priority (see A.5). +ALTER TABLE tracks ADD COLUMN IF NOT EXISTS recording_mbid UUID; +CREATE INDEX IF NOT EXISTS tracks_recording_mbid_idx ON tracks (recording_mbid) WHERE recording_mbid IS NOT NULL; +``` + +### A.2 Predicates + +The enumerated set. Adding a predicate is a code change (a generator +or fusion view that reads it), not a schema migration — predicates +live in the `claims.predicate` free-text column, validated in code. + +| Predicate | subject → object | Meaning | +|---|---|---| +| `credited_main_on` | artist → track | artist is the main credit on the recording | +| `featured_on` | artist → track | artist is a featured performer on the track | +| `credited_main_on_album` | artist → album | artist is the main credit on the album as a whole | +| `featured_on_album` | artist → album | artist is a co-owner / featured credit on the album | +| `alias_of` | artist → artist | subject is an alias of object (directional; confidence = belief) | +| `member_of` | artist → artist | subject is a member of the group object | +| `produced` | artist → track | subject produced the track | +| `composed` | artist → track | subject composed the track | +| `same_label_as` | artist → artist | both artists release on the same label | +| `same_scene_as` | artist → artist | both artists belong to the same scene | +| `influences` | artist → artist | subject influenced object | +| `remix_of` | track → track | subject is a remix of object | +| `cover_of` | track → track | subject is a cover of object | +| `soundtrack_contrib` | artist → franchise | subject contributed to a soundtrack (anime/game/film) | +| `belongs_to_genre` | track/artist → genre | subject belongs to genre (weighted; replaces exact-id match) | + +### A.3 Source trust seed + +```sql +INSERT INTO source_trust (key, trust, description) VALUES + ('curated', 1.00, 'Manual / human-curated claim. Never decayed.'), + ('mb', 0.90, 'MusicBrainz structural spine. High-trust seed; not infallible (re-credits disagree with tags).'), + ('cover_art_archive',0.85,'Cover Art Archive, MB-backed.'), + ('discogs', 0.75, 'Discogs release/artist credits. Strong for releases, weaker for person aliases.'), + ('lastfm', 0.50, 'Last.fm tags + similar. Noisy; used as weak signal.'), + ('listener_behavior',0.40, 'Derived from observed play patterns (e.g. back-to-back play → weak alias_of). User-keyed.'), + ('tag', 0.30, 'File-tag-derived via scanner heuristic. Lowest trust; the &-split fallback.') +ON CONFLICT (key) DO NOTHING; +``` + +### A.4 Fusion read path + +Truth is resolved at read time as a weighted vote across all claims +for a given `(subject, predicate, object)`. The fusion formula: + +``` +fused(subject, pred, object, user_id) = + Σ over claims c with matching (subject, pred, object) + where c.user_id IS NULL OR c.user_id = $user_id + of source_trust(c.source) * c.confidence * recency(c) +``` + +where `recency(c) = clamp(0.1, 1.0, days_since(c.last_reinforced_at) / 180)`, +so a claim never reinforced for 180+ days contributes at 10% floor. + +A materialised view `claim_fusion` exposes the per-(subject, pred, +object, user) fused value. **View shims** over `claim_fusion` provide +the v1 shapes so existing reads survive the transition without rewrite: + +```sql +CREATE OR REPLACE VIEW track_artists_v2 AS +SELECT t.id AS track_id, + a.id AS artist_id, + CASE cf.predicate WHEN 'credited_main_on' THEN 'main' ELSE 'featured' END AS role, + cf.fused_value AS confidence +FROM tracks t +JOIN claim_fusion cf + ON cf.subject_type = 'track' AND cf.subject_id = t.id + AND cf.predicate IN ('credited_main_on','featured_on') + AND cf.object_type = 'artist' +JOIN artists a ON a.id = cf.object_id; + +-- Same shape for albums. Drops albums.artist_id reads over time. +CREATE OR REPLACE VIEW album_artists_v2 AS +SELECT al.id AS album_id, + a.id AS artist_id, + CASE cf.predicate WHEN 'credited_main_on_album' THEN 'main' ELSE 'featured' END AS role, + cf.fused_value AS confidence +FROM albums al +JOIN claim_fusion cf + ON cf.subject_type = 'album' AND cf.subject_id = al.id + AND cf.predicate IN ('credited_main_on_album','featured_on_album') + AND cf.object_type = 'artist' +JOIN artists a ON a.id = cf.object_id; +``` + +`artists.artist_similar` is retired in favour of the graph itself; a +compatibility view maps `same_scene_as` + `alias_of` fused edges to +the old `(artist_id, similar_artist_id, match)` shape for the lifetime +of any read that still wants it. + +Genre hierarchy (`genre.parent_id`) is retired as a separate column. +`belongs_to_genre` claims carry a `confidence` weight; the hierarchy +becomes a `parent_of` claim series on genre entities, and the fusion +view's hierarchical rollup walks these claims instead of a column. + +### A.5 Write paths + +Five independent writers; all UPSERT into `claims`: + +1. **MB spine writer** (in the worker enrichment pipeline): when + `lookupRecording` resolves a recording MBID, fetch the full + `artist-credit` (not just the first entry — extend the MB client). + Write one `credited_main_on` claim (artist = first credit) and one + `featured_on` claim per additional credit, with `source='mb'`, + `confidence=1.0`, `evidence_at=NOW()`. For release-group MBIDs on + albums, write `credited_main_on_album` / `featured_on_album` + analogously. For artist-relation ARs (`member of`, ` collaborations`, + `vocal`/`instrument`), write `member_of` / `featured_on` claims. + MBIDs anchor identity: if the credited artist resolves to a known + MBID, claims attach to that artist entity; otherwise a new artist + is created with the MBID set. +2. **Discogs writer**: `discogs_id` already set on artists today; + extend to write `credited_main_on_album` / `same_label_as` claims + from the release's label and artist credits, `source='discogs'`, + `confidence=0.7`. +3. **Last.fm writer**: existing `artist_similar` fetcher writes + `same_scene_as` claims (`source='lastfm'`, `confidence=match/100`) + instead of the `artist_similar` table. Last.fm tags write + `belongs_to_genre` claims with `confidence=tag.count/100`. +4. **Tag-derived writer** (scanner fallback): when no MBID, the + existing `parseArtistsFromMetadata` heuristic writes + `credited_main_on` / `featured_on` claims with `source='tag'`, + `confidence=1.0` (the trust weight, low at 0.30, is what dims it). + `resolveOrCreateArtist` writes the entity row; the claim carries + the entity, not the name string. Re-keying when MB later resolves + is an UPSERT, not an overwrite. +5. **Listener-behavior writer** (writes into the *user-keyed* region + of `claims`): on play sessions, derive weak edges — adjacent plays + within 30 min of two artists write `same_scene_as` (confidence + 0.3); back-to-back play of two artists within a session writes + `alias_of` (confidence 0.2). These are *evidence*, not conclusions; + they fuse with the objective `alias_of` claim from MB (if any) at + read time. Behavioral claims decay by `last_reinforced_at`; the + belief-strengthening path in System B reinforces them on repeat. + +All writes UPSERT on `(subject, predicate, object, source, user_id)`: +re-fetching a source refreshes `last_reinforced_at` and `evidence_at` +without duplicating rows. + +### A.6 Replaces + +| Old surface | Status | +|---|---| +| `artist_similar` table | Retired; replaced by `same_scene_as` / `alias_of` claims. Compat view for the transition. | +| `track_artists` (as truth) | Retired as truth; survives as a *view* `track_artists_v2` over `claim_fusion`. No insert path; reads only. | +| `albums.artist_id` (single-FK ownership) | Survives as a denormalised pointer (written by a trigger off `claim_fusion`'s main credit) for back-compat. Truth lives in `album_artists_v2` view. | +| `genre.parent_id` (column) | Retired; replaced by `parent_of` claims on genre entities. | +| Scanner `resolveAlbumArtist` `parts[0]` behaviour | The heuristic now writes *claims*, not truth. `parts[1:]` become `featured_on_album` claims instead of being dropped. | +| MB client `best['artist-credit']?.[0]` first-credit-only read | Extended to read the full `artist-credit` array. | +| Old Phase 2 (`album_artists` junction table) | Not built — subsumed by A. | +| Old Phase 3 (re-credit pass over whole library) | Not built as a destructive overwrite. MB claims UPSERT into the graph; no `track_artists.source` column, no destructive re-credit. | +| Old Phase 5 (`artist_groups`, `artist_group_members`) | Not built — `alias_of` / `member_of` claims are the grouping. | + +### A.7 Acceptance + +- A track tagged `artist="MF DOOM & Madlib"` with a resolved recording + MBID shows a `credited_main_on` claim from MB for "Madvillain" (if MB + credits Madvillain) AND a `credited_main_on` claim from the tag for + "MF DOOM". Both coexist. The fusion view, weighted by trust (MB 0.90 + > tag 0.30), shows "Madvillain" as the higher-confidence main credit. +- An album tagged `albumartist="A & B"` has two `credited_main_on_album` + claims — A from tags, A and B from MB if MB credits both. The album + appears on both A's and B's artist pages via `album_artists_v2`. +- `SELECT * FROM claims WHERE subject_type='artist' AND subject_id=$doom + AND predicate='alias_of'` returns rows from MB (if it asserts an + alias) and from listener_behavior (if the user has played DOOM and + Madvillain back-to-back). Both decay; both reinforce. +- A never-played genre fished via the future `/vibe/from-genre` path + reaches tracks through the `belongs_to_genre` claims + hierarchical + rollup, not exact-id match. + +### A.8 Risks + +- **Fusion reasoning cost.** Every read now resolves a vote across + multiple claims. Mitigation: `claim_fusion` materialised view, + refreshed on `claims` insert/update; reads hit the view, not the raw + table. Estimate: a single `(subject, predicate, object)` fused value + is a point lookup on the materialised view. +- **MB rate limits during spine backfill.** Initial population walks the + whole library (~3.9k tracks) fetching full `artist-credit`. Expected + hours, not minutes, gated by MB's rate policy. +- **Display flips during transition.** When the fusion view's + high-confidence main credit is "Madvillain" but the file tag says + "MF DOOM & Madlib", the library view shows "Madvillain". This is + intended (MB is the structural spine) but may surprise the user at + first. Both claims remain auditable via `SELECT * FROM claims`. +- **Tag-only tracks** (no MBID) inherit the lowest-trust claims. This + is correct: the graph is honest about how much it knows. + +--- + +## System B — Listener model + +Maintains probabilistic beliefs about the listener. Keyed on +`user_id` from the start; behaviours and beliefs cannot be retrofitted +later without painful re-keying once belief data accumulates. + +### B.1 Schema + +```sql +-- Evidence: every observed interaction that should influence a belief. +-- Append-only. Never edited or deleted (purge policy separate). +CREATE TABLE evidence ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + entity_type TEXT NOT NULL, -- 'track'|'artist'|'genre'|'album' + entity_id UUID NOT NULL, + signal TEXT NOT NULL, -- see B.3 + profile TEXT NOT NULL, -- which profile this evidence feeds; see B.2 + weight REAL NOT NULL, -- signal strength, set by the signal rule + context JSONB, -- optional: {session_id, hour, weekday, activity, ...} + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX evidence_user_entity_idx ON evidence (user_id, entity_type, entity_id, created_at DESC); +CREATE INDEX evidence_user_profile_idx ON evidence (user_id, profile, created_at DESC); + +-- Listener beliefs: the derived state. Continuously decayed; reinforced by evidence. +CREATE TABLE listener_beliefs ( + user_id UUID NOT NULL, + profile TEXT NOT NULL, -- see B.2 + entity_type TEXT NOT NULL, + entity_id UUID NOT NULL, + dimension TEXT NOT NULL, -- 'affinity'|'fatigue'|'familiarity'|'novelty_tolerance' + value REAL NOT NULL CHECK (value >= -1.0 AND value <= 1.0), + confidence REAL NOT NULL CHECK (confidence >= 0 AND confidence <= 1.0), + evidence_count INTEGER NOT NULL DEFAULT 0, + last_reinforced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_decayed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, profile, entity_type, entity_id, dimension) +); +CREATE INDEX listener_beliefs_user_profile_idx ON listener_beliefs (user_id, profile, entity_type, entity_id); +``` + +### B.2 Profiles + +Enumerated. Adding a profile is a code change (an evaluator or +generator that reads it), not a schema migration. + +| Profile | Decay half-life | Fed by signals | Read by | +|---|---|---|---| +| `longterm` | 365 days (slow) | replays, manual search, add-to-favorites, multiple sessions | comfort, deep-dive generators | +| `obsession` | 14 days (fast) | disproportionate play concentration | adjacent, novelty generators (to bound) | +| `discovery` | 30 days | play-of-never-before-seen-entity, accept-after-skip | discovery, experimental generators | +| `negative` | 180 days | skips, queue-removal, hide, manual-delete | all generators (exclusion) | +| `forgotten` | n/a (derived) | `longterm` high-affinity + 90d no plays | revival generator | +| `contextual` | 7 days | context-tagged play sessions | contextual generator | + +`forgotten` is derived nightly from `longterm` beliefs that have not +been reinforced in 90 days; it is not written by signals directly. + +### B.3 Signal → weight rules + +A signal writes one `evidence` row with a `weight`. The weight feeds +into the belief update (B.4). Signal weights are constants, tunable: + +| Signal | Profile | Weight | Direction | +|---|---|---|---| +| `playback_completed` | longterm | +0.10 | affinity up | +| `replay_within_24h` | longterm | +0.25 | affinity up | +| `replay_within_24h` | obsession | +0.40 | affinity up | +| `manual_search` | longterm | +0.50 | affinity up | +| `add_to_favorites` | longterm | +0.60 | affinity up | +| `shared` | longterm | +0.70 | affinity up | +| `play_of_never_seen` | discovery | +0.05 | discovery tolerance up | +| `accept_after_probe` | discovery | +0.30 | affinity up (mild) | +| `skip_quick` (≤ 30s) | negative | -0.20 | affinity down | +| `skip_repeated` | negative | -0.40 | affinity down | +| `queue_removed` | negative | -0.30 | affinity down | +| `hidden` | negative | -0.60 | affinity down | +| `manual_deleted` | negative | -0.90 | affinity down (strong) | + +Neutral actions (seek, pause, volume) write no evidence. Lack of +interaction writes no evidence (a core principle: *absence of +interaction is not dislike*). + +`weight` is per-signal; an event may write multiple `evidence` rows +across multiple profiles (a `replay_within_24h` writes both a +`longterm` +0.25 row and an `obsession` +0.40 row). + +### B.4 Belief update + decay + +On each new evidence row matching `(user, profile, entity, dimension)`: + +``` +belief.value = clamp(-1, 1, belief.value + Σ new_evidence.weight * (1 - belief.confidence)) +belief.confidence = clamp(0, 1, belief.confidence + 0.05) +belief.evidence_count += count(new rows) +belief.last_reinforced_at = NOW() +``` + +Daily decay job (or on-read lazy decay): + +``` +h = (NOW() - belief.last_decayed_at) / profile.halflife_days +belief.value *= 0.5 ^ h +belief.confidence *= 0.5 ^ h -- confidence also decays +belief.last_decayed_at = NOW() +``` + +A belief not reinforced for 3 halflives approaches zero. Old evidence +becomes irrelevant; new evidence dominates. This prevents the v1 +failure mode where heavily-played artists keep winning forever. + +### B.5 Replaces + +| Old surface | Status | +|---|---| +| `feedback(action='promoted')` | Not written. The implicit-promote intent from old §1.1 lives as a `playback_completed` evidence row → longterm affinity up. | +| `feedback(action='disliked'|'skipped'|'deleted_permanent')` | Not written. Same signals now write `negative`-profile evidence rows. The `feedback` table is retired; existing data is backfilled into `evidence` once and then the table is dropped. | +| `favorites` table | Survives as a UI collection (the Keep button is a UI concept, separate from engine affinity). Old §1.1 explicitly kept this split; we keep it. Keep writes a `favorites` row AND a `add_to_favorites` evidence row. | +| The clamped genre-affinity term in `local_pool` | Retired. Affinity is now per-(user, profile, entity) in `listener_beliefs`; generators read beliefs directly. | +| `play_history` reads by nothing in v1 | `play_history` survives (it is the audit log of plays) but the scorer doesn't read it; the evidence writer does, once per play, converting a play row into evidence signals. | + +### B.6 Acceptance + +- After one completed play of a never-played track, a `longterm` + affinity belief for that track exists at `value=+0.05`, `confidence=0.05`. +- After 5 completed replays within a week, that track's `longterm` + affinity is above +0.30; the artist's affinity (rolled up from track + beliefs) is above +0.20. +- A track skipped 3× in 30 days has a `negative`-profile affinity below + -0.50. The session director's exclusion filter consults this. +- A track not played for 365 days has decayed its `longterm` affinity + to ~50% of peak; it appears in the `forgotten` derived profile. +- `SELECT value FROM listener_beliefs WHERE user_id=$1 AND profile='obsession' + AND entity_type='artist' ORDER BY value DESC LIMIT 5` returns the current + obsessions, which the session director uses to bound overplay. + +--- + +## System C — Candidate generators + +Recommendations originate from independent generators. Each proposes +candidates without knowledge of final ranking — the session director +(D) does the ranking, mixing, and session composition. Each generator +returns candidates with a **graph-path explanation** (the chain of +claims that led to this candidate), so every recommendation is +auditable. + +### C.1 Generator interface + +```ts +interface Generator { + id: string; // 'comfort' | 'adjacent' | 'discovery' | ... + run(ctx: GeneratorContext): Promise<Candidate[]>; +} + +interface GeneratorContext { + userId: string; + listenerState: ListenerState; // from D's state builder + beliefs: BeliefReader; // reads listener_beliefs + graph: GraphReader; // reads claim_fusion (A) + profile: ProfileName; // which profile this generator prefers + recentExclusions: Set<string>; // (entityType, entityId) already churned this session +} + +interface Candidate { + trackId: string; + generatorId: string; + explanation: ClaimEdge[]; // the graph path that produced this candidate + // No score. Generators don't rank; the director does. +} +interface ClaimEdge { + subjectType: string; subjectId: string; + predicate: string; + objectType: string; objectId: string; + fusedValue: number; +} +``` + +### C.2 The generators + +Each generator wraps a graph query. All read `claim_fusion` and +`listener_beliefs`; all return `Candidate[]` with explanations. + +1. **Comfort** — reads `longterm` affinity beliefs, picks tracks by + artist with affinity > +0.5, fuses with `credited_main_on` / + `featured_on` claims to find tracks by those artists. Goal: + maintain satisfaction. +2. **Adjacent** — for each seed artist in current session state, walks + 1–2 graph hops: `seed → credited_main_on → track → featured_on → + artist → member_of → group → member_of → artist`. Returns tracks + by reached artists, excluding those in the comfort pool. +3. **Discovery** — picks tracks whose artists have no `longterm` / + `obsession` belief (truly unfamiliar), filtered to those with at + least one graph edge to a trusted artist (`same_scene_as`, + `same_label_as`, `produced` by a producer who produced a favourite). + Reads the `discovery` profile's `novelty_tolerance` to set how many + to return. +4. **Deep-dive** — prioritises complete albums. Picks an album owned + (via `album_artists_v2`) by an artist with `obsession` affinity and + returns overlooked tracks (those with low `familiarity` belief) in + album order. Prefers tracks with no play history. +5. **Revival** — reads the `forgotten` derived profile, returns tracks + whose `longterm` affinity is high but `last_reinforced_at` is old + (> 90 days). Time window is adaptive: nostalgia horizon scales with + how established the longterm profile is. +6. **Novelty** — queries for tracks with `release_date` in the last 60 + days whose artists share a `same_label_as` / `same_scene_as` edge + with a favourite, OR a `produced` edge from a known producer. Most + recent first, gated by `discovery` profile tolerance. +7. **Experimental** — deliberately challenges current assumptions. + Finds genres with very few `longterm` beliefs of any sign (i.e. + the system is uncertain), picks tracks from those genres with the + highest network-distance from favourites. Goal: learning, not + satisfaction. Run rate is low (one track per N, configurable). +8. **Contextual** — reads the `contextual` profile. If the listener + state has a context tag (coding / driving / sleeping), returns + tracks whose `listener_beliefs` context entries match that + context. + +### C.3 Replaces + +| Old surface | Status | +|---|---| +| `local_pool` CTE | Retired. Comfort and adjacent generators together cover what local_pool tried to be (genre-overlap + artist_sim + same-artist + audio + jitter). | +| `probation_pool` CTE (gated on `artist_sim > 0`) | Retired. Discovery + deep-dive generators cover the intended-but-unshipped behavior. | +| `getVibeChunkFromGenre` (stateless genre seed) | Survives briefly as a thin wrapper over the discovery generator seeded with a genre; cleaned up when D lands. | + +### C.4 Acceptance + +- Each candidate returned by any generator carries a non-empty + `explanation` array (graph path). A recommendation with no graph + path is invalid; generators refuse to return it. +- Seeding a DOOM track: adjacent generator returns tracks by artists + reached via `featured_on` from DOOM tracks (i.e. Madlib's other + projects) and via `member_of` from DOOM (i.e. Madvillain tracks) — + *all as candidates*, with explanations; the session director decides + whether to use them given the fatigue model. +- A genre with no library coverage (seeded via `/vibe/from-genre`) + returns zero comfort candidates and a non-empty discovery candidate + list — surfacing fresh material, not the empty result of v1's + `artist_sim > 0` gate. + +--- + +## System D — Session director + +The planner. Replaces `getNextVibeChunk` entirely. Where the old CTE +selected the highest-scoring 20 tracks in one query, D maintains a +rolling 20–50-track plan that is rewritten on every feedback event, +pursuing invisible long-term goals (finish an album over days, +introduce an artist gradually, balance decades) while optimising +multiple objectives simultaneously. + +### D.1 Listener state + +Built at the start of each session and updated on each play/skip: + +```sql +-- Per-session state; persisted across heartbeats so resumes stay coherent. +CREATE TABLE session_state ( + session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_interaction TIMESTAMPTZ NOT NULL DEFAULT NOW(), + context TEXT, -- 'coding'|'driving'|'sleeping'|NULL (auto-detected or manual) + state_vector JSONB NOT NULL -- the computed state; see below +); +``` + +`state_vector` fields (computed on state build, refreshed on each event): + +```jsonc +{ + "energy": 0.62, // avg energy of last 5 plays + "focus": 0.40, // manual focus intent, 0..1 + "novelty_hunger": 0.30, // from discovery profile's novelty_tolerance + "artist_fatigue": { "<artistId>": 0.71, ... }, // see D.2 + "genre_fatigue": { "<genreId>": 0.55, ... }, + "language_fatigue": { "ja": 0.83, "en": 0.10 }, + "vocal_fatigue": 0.40, // 0 = vocals ok, 1 = want instrumental + "session_age_min": 73, + "current_mood": "energetic", + "target_entropy": 0.55 // see D.5 +} +``` + +### D.2 Fatigue model + +Everything gets fatigued. Everything recovers over time. Fatigues are +per-dimension cumulative decays over recent play history, NOT session +counters (v1's `artist_play_count` batch counter is retired). + +For each dimension X (track / artist / genre / language / vocalist): + +``` +fatigue_X(entity, t) = Σ over plays p of X in last T_window + of exp( -(t - p.played_at) / decay_X ) + +T_window = 24h (artist, genre) | 7d (track) | 2h (language, vocalist) +decay_X = 8h (artist, genre) | 30d (track) | 1h (language, vocalist) +``` + +- `track` fatigue: a track played in the last hour `exp(-(0)/30d)=1`; + played yesterday `exp(-(1d)/30d)≈0.97` — *strong* recent-play penalty + on tracks; this is the v1 missing cross-session overplay fix, made + structural. Played a month ago: `exp(-(30d)/30d)≈0.37`. +- `artist` fatigue: rolled up from track fatigue over the artist's + tracks; collapses MF DOOM / Madvillain / Viktor Vaughn **iff** their + `alias_of` claims fuse them at read time (which depends on whether + MB or listener-behavior has asserted the alias). This is the honest + v1 Phase 5 fix — aliasing is a graph belief, not a flag. +- `genre` / `language` / `vocalist` fatigue: same formula, same + recency-aware decay. + +A candidate's final rank incorporates `1 - fatigue_X(candidate, now)` +as multipliers per dimension; the v1 `GREATEST(0.15, ...)` floor +becomes a tunable per-dimension floor in `session_floor` config. + +### D.3 Diversity budgets + +Instead of hard caps ("max 1 per artist per chunk", "max 2 per genre"), +a budget the planner spends. Per-session, refreshed at session start: + +```sql +INSERT INTO source_trust VALUES ('budget_default', 0.0, 'non-graph config sentinel') ON CONFLICT DO NOTHING; + +CREATE TABLE diversity_budgets ( + user_id UUID NOT NULL, + dimension TEXT NOT NULL, -- 'artist'|'genre'|'language'|'instrumental'|'new_artist'|'favorite' + budget_share REAL NOT NULL, -- fraction of session, e.g. 0.20 + horizon_min INTEGER NOT NULL, -- budget window, e.g. 30 (min) + PRIMARY KEY (user_id, dimension, horizon_min) +); +``` + +Default budgets (seeded on first session per user): + +```jsonc +{ + "artist": { "share": 0.20, "horizon": 30 }, + "genre": { "share": 0.40, "horizon": 30 }, + "language": { "share": 0.60, "horizon": 30 }, + "instrumental": { "share": 0.10, "horizon": 30 }, + "new_artist": { "share": 0.15, "horizon": 60 }, + "favorite": { "share": 0.25, "horizon": 60 } +} +``` + +"Not more than 2 songs of the same artist in the last 30 min" becomes +a 20%-of-30min budget. The planner spends, replenishes at window edge. +If recent listening has blown a budget, the planner refuses further +spends in that dimension — the structural replacement for v1's +diversity cap. + +### D.4 Arcs + +The planner doesn't pick songs; it picks *arcs* and slots songs into +them. Templates: + +- **Comfort arc**: known → known → adjacent → favorite. +- **Discovery arc**: favorite → similar → new → favorite. +- **Energetic arc**: medium → high → peak → cooldown. +- **Late-night arc**: soft → ambient → acoustic → slow electronic. + +At session start, given the state vector and the long-term schedule +(D.7), the planner picks an arc template and fills it. The plan is a +list of "slots" (`{arc_position, role}`, e.g. `{1, "peak"}`); the +planner queries the matching generator for each slot. On replan, +remaining slots can shift arc. + +### D.5 Surprise, callbacks, entropy target, anti-loop + +- **Surprise budget**: ~1 per hour (configurable). Reserved slot in + the arc for a forgotten favorite, live version, cover, acoustic + version, producer side project, or old obsession. Surfaced via the + revival generator with a `surprise=true` flag. +- **Callbacks**: every N tracks the planner intentionally re-introduces + an artist / theme / energy level from earlier in the session (or + earlier session that day). Makes the session feel intentional. +- **Entropy target**: the state vector carries `target_entropy`. If + the autocorrelation of last-20 chosen-track features is too high + (predictable), entropy goes up (planner prefers experimental / + discovery candidates). If too low (chaotic), planner injects + comfort. Target is **controlled unpredictability**, not randomness. +- **Anti-loop detector**: continuously monitor the last-50 plan + choices for collapsing into a narrow graph region (same artist / + label / producer / genre / decade / BPM / mood / language). If + collapse detected, the planner forcibly expands: zero-out the + dominant dimension's budget for the next window and surge a + non-dominant generator. + +### D.6 Repetition rules + +Adaptive minimum-distance, not absolute "don't repeat": + +```sql +CREATE TABLE repetition_rules ( + user_id UUID NOT NULL, + dimension TEXT NOT NULL, -- 'track'|'artist'|'album'|'genre'|'energy' + min_distance INTEGER NOT NULL, -- adaptive; minutes + PRIMARY KEY (user_id, dimension) +); +``` + +Defaults: `track=2h, artist=20min, album=no immediate; spread across +hours, genre=don't-dominate, energy=smooth transitions`. All adapt: +if a listener shows high `focus` (deep work signal), distances relax +(loop tolerance up); if skipping-after-replay pattern appears, +distances tighten. + +### D.7 Long-term scheduling + invisible goals + +The planner also has week-scale objectives, tracked in +`session_state.state_vector.goals`: + +- Finish an album over several days (track which album is "in + progress"; the deep-dive generator keeps returning its overlooked + tracks until the album is fully played). +- Introduce a new artist gradually (e.g. one track per session for a + week, escalating if survival rate is high). +- Revisit old favorites monthly. +- Balance decades, languages, producers (the budgets cover most of + this; the planner periodically nudges an under-represented decade to + surge). +- Complete discovery probation (see System E). +- Guarantee at least one surprise per hour. + +The listener should never notice these goals directly. + +### D.8 Don't maximise enjoyment + +The planner optimises multiple objectives simultaneously: + +``` +maximise: + enjoyment (predicted from beliefs × relevance) + discovery (fraction of unfamiliar entities in the plan) + diversity (1 - Herfindahl index across artists in horizon) + learning (information gain on uncertain beliefs) + session coherence (arc-template adherence) + long-term freshness (entropy target met) + +minimise: + fatigue (cumulative per-dimension fatigue) + repetition (autocorrelation of recent choices) + predictability (1 - entropy) + wasted discoveries (candidates surfaced then immediately skipped) +``` + +This is why single-objective score maximisation (the v1 approach) +collapses to "ADO, Yoasobi, ADO, Zutomayo, ADO" — those are the +"optimal" tracks by predicted enjoyment alone. + +### D.9 Plan + replan loop + +``` +session start + ↓ +build state_vector (D.1) + ↓ +pick arc template (D.4) + target entropy (D.5) + ↓ +for each slot in arc: + query matching generator (C) → candidates + rank candidates across the D.8 objectives + pick winner, respecting budgets (D.3) + repetition rules (D.6) + ↓ +20–50 track plan + ↓ +playback + ↓ +on play / skip / manual action: + write evidence (B.3) + refresh state_vector fatigue (D.2) + if plan slot < 10 remaining OR anti-loop fires OR entropy drift > 0.2: + replan from current state + ↓ +loop +``` + +### D.10 Replaces + +| Old surface | Status | +|---|---| +| `getNextVibeChunk` CTE (~220 lines in `db.service.ts`) | Retired entirely on D ship. | +| `recommendation_batch_track` exclusion set | Survives as the recent-exclusions `Set` passed to generators; no longer the source of artist-play-count. | +| `recommendation_batch` row + `seed_track_id` center-walk in `recordPlay` | Retired. The center-walk was a hack for "engine can't escape the seed neighbourhood"; D's arc + fatigue together replace it. | +| Old §1.2 (recency term added to local_pool) | Not a scored term anymore; recency is a fatigue-dimension multiplier in D.2. | +| Old §1.3 (track-level overplay penalty) | D.2's `track` fatigue, structural. | +| Old §1.4 (artist-level overplay penalty, identity-best-effort) | D.2's `artist` fatigue rolled up via alias fusion in A. Identity collapse happens *iff the claims graph says so*, not as a separate code path. | +| Old §1.5 (lower W_SAMEART) | No `W_SAMEART` to tune; the comfort generator alone handles "more of this artist" and is naturally bounded by D.3's artist budget. | +| Old §1.7 (cap on `track_artists.artist_id` not name string) | Subsumed by D.3's budgets (artist dimension). | + +### D.11 Acceptance + +- After 6 hours of listening, the listener is still engaged, has + discovered at least one unfamiliar but tolerable track, has not + become fatigued by any single artist / genre / language, and the + next session would still feel fresh. +- Seeding a DOOM track does not collapse the next chunks into DOOM + pseudonyms even though alias fusion may treat them as one artist — + because D.2's artist fatigue rises fast in the session, D.3's + artist budget blocks further spends, and D.4's arc pulls toward + adjacent generators (Madlib's other projects reached via graph + hops, not DOOM). +- Within a session, tracks played in the last hour do not re-appear + (track fatigue multiplier ≈ 0 after recent plays). +- Across sessions, the same top-20 does not return: track fatigue + half-life of 30 days means yesterday's plays still dampen today's + rank. +- Forgotten favorites resurface naturally (revival generator + monthly + long-term goal). +- Anti-loop detector fires when the dominant dimension's share exceeds + budget × 1.5, forcibly diversifying the next window. + +--- + +## System E — Acquisition pipeline + +The library is not the universe. E continuously searches beyond the +current collection, identifies music worth evaluating, acquires it +(via the unbuilt yt-dlp worker, `progress.md:29`), validates it, and +either permanently integrates it into the graph (A) or discards it. +Discovery is independent of playback; it writes into A. + +### E.1 Discovery sources + +Six independent strategies run continuously as low-priority worker +jobs: + +1. **Graph exploration** — walk the graph beyond the library. For each + favourite artist (per `longterm` beliefs), follow `featured_on` / + `member_of` / `produced` / `same_label_as` / `same_scene_as` edges + to artists not in the library. Each traversal is a discovery path + candidate. +2. **Release monitoring** — monitor favourite artists, related artists + (graph adjacents), labels, and producers for new releases. New + releases become discovery candidates at high priority. +3. **Scene exploration** — discover music through communities rather + than artists: city scenes, internet communities, niche genres, + underground movements, independent labels. Avoids recommendation + loops around the same popular artists. +4. **Temporal exploration** — search different musical eras for + forgotten classics, overlooked releases, albums that became + influential years later. +5. **Relationship expansion** — instead of "people also listen to", + prefer structural relationships: same producer, same composer, + live band members, guest vocalists, touring partners, soundtrack + contributors. +6. **Curiosity exploration** — dedicated exploration budget for + unfamiliar genres, different languages, experimental music, + geographically distant scenes. Success is measured by learning, + not immediate satisfaction. + +### E.2 Candidate universe + +Before downloading, discoveries live as `claims` rows with +`subject_type='track'` and a special marker — they are *candidate* +tracks, not library tracks. The candidate carries the discovery +source, the relationship path that led to it, an estimated relevance, +and an explanation. + +This reuses `claims` rather than a dedicated table, with a dedicated +predicate: + +```sql +-- A discovery candidate is a claim: subject=track (candidate), predicate='discovery_candidate', object=source artist / scene / label. +-- The 'confidence' field is the estimated relevance; 'raw' holds the full path + explanation. +INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence, raw) +VALUES ('track', $candidateId, 'discovery_candidate', 'artist', $relatedArtistId, 'graph_exploration', $relevance, $pathJson) +ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) DO NOTHING; +``` + +Candidate tracks themselves are stored as stub rows in a +`discovery_candidates` table — thin rows holding the external identity +only, no library path / audio / metadata yet: + +```sql +CREATE TABLE discovery_candidates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source TEXT NOT NULL, -- 'mb'|'discogs'|'lastfm'|'spotify'|... + external_id TEXT NOT NULL, -- MBID / discogs_id / etc. + title TEXT, + artist_credit JSONB, -- the full artist-credit array from the source + notes JSONB, -- discovery path, source-only fields + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_eval_at TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'candidate', -- 'candidate'|'acquiring'|'probation'|'retained'|'retired' + UNIQUE (source, external_id) +); + +-- Keeping the graph reference: +-- discovery_candidates.id is referenced by claims rows with subject_type='track' (UUID aligns). +``` + +### E.3 Acquisition policy + queue + +Not every candidate downloads. Decision factors: + +- expected usefulness (relevance confidence from the discovery claim) +- novelty (does the listener's `discovery` profile tolerate this + territory?) +- storage budget +- artist diversity (don't acquire 10 tracks from one new artist in a + day) +- existing backlog depth +- current listener fatigue (don't acquire more of a fatigued artist) +- current exploration budget share + +The decision maximises **expected information gain**, not download +count. + +Priority queue: + +| Priority | Source | +|---|---| +| 1 (highest) | favourite artists' new releases | +| 1 | active obsession new releases | +| 2 | adjacent artists, collaborations, graph discoveries | +| 3 (lowest) | experimental discoveries, curiosity experiments | + +Downloads happen invisibly (yt-dlp worker, throttled, low bandwidth). +On successful download, the audio is scanned (existing scanner), which +writes `credited_main_on` / `featured_on` claims and a `tracks` row. +The discovery_candidate row transitions to `probation`. + +### E.4 Probation + +Downloaded music is never trusted immediately. Every acquisition +enters probation. During probation, the session director (D) +occasionally injects probation tracks into normal sessions — the +listener should not feel they are being tested. + +Probation is a `tracks.probation_status` column (new), one short +migration: + +```sql +ALTER TABLE tracks ADD COLUMN IF NOT EXISTS probation_status TEXT + DEFAULT 'retained' CHECK (probation_status IN ('probation','retained','retired')); +ALTER TABLE tracks ADD COLUMN IF NOT EXISTS probation_entered_at TIMESTAMPTZ; +CREATE INDEX tracks_probation_idx ON tracks (probation_status) WHERE probation_status = 'probation'; +``` + +Existing library tracks default to `retained`. Newly acquired tracks +are `probation` with `probation_entered_at = NOW()`. + +Each probation track accumulates evidence (B.3) over multiple +sessions — single interactions rarely provide enough. Possible +outcomes: + +- **retain** — survival threshold met; probation_status → `retained`. + Associated artist's `longterm` affinity gets a small bump, the + discovery path that produced this candidate gets reinforced (a meta + signal for E.5). +- **archive** — kept on disk but hidden from normal sessions; exempt + from the planner. +- **delete** — file removed, `tracks` row marked `retired`. Library + is not an ever-growing archive. +- **ignore temporarily** — back to candidate state for re-evaluation + later; rarer path. + +Probation duration adapts to confidence: a candidate discovered via a +trusted path (favourite producer's new signing) gets a longer +probation than a curiosity-experiment candidate. + +### E.5 Meta-learning + +The discovery system continuously evaluates itself. A periodic job +writes claims back into the graph about which *strategies* and *graph +paths* have produced long-term retainers, vs which consistently retire: +`source_trust` already tunes per-source; meta-learning additionally +tunes per-predicate-path: + +- which discovery sources (graph_exploration, release_monitoring, + scene_exploration, ...) produce long-term favourites? +- which graph paths consistently fail? (e.g. `same_label_as` may be a + weak edge; down-weight it.) +- which labels repeatedly introduce successful artists? +- which exploration depth performs best? +- which experiments produce the highest information gain? + +This meta-learning itself writes back as `source_trust` adjustments +and as tunable per-path-weight constants. Discovery learns how to +discover better, not just what to recommend. + +### E.6 Acceptance + +- A discovery candidate surfaced via "producer A produced favourite B + AND new artist C" is auditable: `SELECT raw FROM claims WHERE + subject_id=$candidateId AND predicate='discovery_candidate'` shows + the full path. +- A downloaded probation track on which the listener completed 3 plays + in its first 2 sessions transitions to `retained` automatically. +- A downloaded probation track skipped on every injection retires to + `retired` after its probation window; file is removed; library does + not accumulate indefinitely. +- The meta-learning job, run weekly, down-weights a discovery strategy + (e.g. `scene_exploration`) whose recent candidates have a <20% + retention rate, observable in `source_trust` deltas or per-path + weight constants. + +### E.7 Replaces + +Nothing — E is net new. It consumes A (graph) and writes back into A. +Built on top of the yet-unbuilt yt-dlp worker (`progress.md:29`), +independent of B/C/D. + +--- + +## §F — Phase 4: Image quality (preserved from old v2) + +**This section is preserved verbatim from the previous v2 doc.** It is +orthogonal to recommendation; the bad-image problem is a provenance +problem, unrelated to the engine. Ships any time, independent of A–E. + +### F.1 Schema + +```sql +CREATE TABLE image_candidates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entity_type TEXT NOT NULL CHECK (entity_type IN ('artist','album')), + entity_id UUID NOT NULL, + source TEXT NOT NULL, + url TEXT, + width INTEGER, + verified BOOLEAN DEFAULT FALSE, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (entity_type, entity_id, source) +); +CREATE INDEX image_candidates_entity_idx ON image_candidates (entity_type, entity_id); +``` + +- `artists.image_path` and `albums.artwork_id` remain as the + "currently preferred" denormalised pointer, written by the selection + step. Existing queries keep working. + +### F.2 Enrichment write + +Each image-fetch step (Wikidata, TheAudioDB, Fanart, iTunes, Deezer, +Discogs, Last.fm, Cover Art Archive) writes a `image_candidates` row +even on failure to find one — a "negative" row with `url=NULL` so we +don't re-fetch that source for that entity until the row is aged out. + +### F.3 Selection + +A selection step (worker job or enrichment sub-step) picks the +preferred URL by tier: + +1. Wikidata via MBID (verified, broad coverage) — highest tier. +2. TheAudioDB via MBID. +3. Fanart via MBID. +4. Cover Art Archive (albums) / Deezer (albums) — high-res. +5. iTunes upscaled to 600 — broad coverage fallback. +6. Last.fm — last resort. +7. Wikimedia via name match — excluded (historically wrong; migrations + cleared these twice). + +Tiers are a config table or constants, not magic strings in code. +Selection writes the winner into `artists.image_path` / +`albums.artwork_id`. + +### F.4 Re-evaluation + +- `image_candidates` rows older than `N` days (config, default 90) are + eligible for re-fetch. A periodic job re-runs enrichment for stale + candidates, replacing rows. +- The selector re-runs whenever candidates change. So if a low-tier + winner was selected and a higher-tier candidate lands later, the + preferred pointer is upgraded in place. + +### F.5 Acceptance + +- After re-enrichment, an artist that previously showed a 100×100 + Last.fm thumbnail shows the Wikidata/TheAudioDB image instead. +- Re-running enrichment does not re-fetch sources that already returned + (negative cache). +- Selection is auditable: `SELECT * FROM image_candidates WHERE + entity_id = X` shows every candidate considered. + +--- + +## Rollout + +Recommended order, each system independently shippable; the old v1 +CTE stays until D lands: + +1. **A** (knowledge graph + claim_fusion view). Existing queries move + to the compat views (`track_artists_v2`, `album_artists_v2`); v1 + engine keeps running against the views. MB spine backfill runs in + the background. +2. **B and E in parallel** (B listener model + evidence writer; E + acquisition pipeline + yt-dlp worker + probation). Both need only + A. The evidence writer starts converting play_history into + evidence; the existing CTE ignores evidence for now. +3. **C** (generators). Built and run in shadow mode alongside the v1 + CTE — both produce chunks; the UI shows v1 chunks, but C's outputs + are logged for comparison. Generators don't replace v1 reads until + D ships. +4. **D** (session director). D switches over and the v1 CTE is + deleted in the same release. Acceptance is the session-feel test + (D.11). +5. **Phase 4 (image candidates)** at any point. Independent. + +No phase is blocked except B/E on A, C on A+B, D on C+B. Phase 4 is +fully independent. + +## Retiring v1 — explicit deletion list + +On System D ship, this code goes: + +- `db.service.ts` `getNextVibeChunk` (~220 lines). +- `recordPlay`'s center-walk (`UPDATE recommendation_batch SET + seed_track_id = $2 ...`, line ~580–590). +- The `artist_play_count` decay term (line ~829). +- The `W_SAMEART`, `W_ARTSIM`, `W_FEEDBCK`, `W_AUDIO`, `W_RANDOM` + constants + `local_pool` / `probation_pool` CTEs (lines 717–866). +- The "max 1 per artist per chunk" cap, replaced by D.3's budgets. +- `getVibeChunkFromGenre` (it becomes a thin wrapper over the discovery + generator; then collapses into D's session-by-genre entry). +- The `feedback` table write paths (`recordSkip`, `recordFeedback`, + hardDelete insert). The `feedback` table itself is dropped after + backfill into `evidence`. +- `recommendation_batch_track` exclusion set (replaced by D's + recent-exclusions set, in-memory per session). +- `albums.artist_id` single-FK pointer (kept as a denormalised + trigger-maintained column off the fusion view; removed as a *read* + source). +- `artist_similar` table (compat view retained briefly, then dropped). +- `genre.parent_id` column (replaced by `parent_of` claims; column + dropped after a backfill claims-migration). + +## Out of scope + +- **Filesystem reorganisation.** Confirmed out of scope. The bind is + read-only; the DB is the index; reorganising the FS inverts the + dependency in the wrong direction. +- **Auth itself.** Schemas are `user_id`-keyed from the start so no + retrofit is needed later, but building auth is a separate project + (progress.md #30). +- **A user-facing override UI for MB credits.** Competing claims + coexist in the graph; resolution at read time is weighted. A future + UI can show "per MB" vs "per tag" and let the user assert a + `curated` claim (trust 1.0) that overrides. Out of scope here. +- **Manual artist-group / alias curation UI.** `alias_of` is a graph + belief; `curated` claims (trust 1.0) override the learned belief + when human assertion is needed — but the UI to do that is separate. \ No newline at end of file diff --git a/docs/architecture/v2-fix-plan.md b/docs/architecture/v2-fix-plan.md new file mode 100644 index 0000000..0186f3e --- /dev/null +++ b/docs/architecture/v2-fix-plan.md @@ -0,0 +1,797 @@ +# v2 Fix Plan — foolproof execution + +This plan fixes the gaps between the overnight v2 work and +`docs/architecture/09-recommendation-and-identity-v2.md`. Every step +has exact file paths, exact old/new code, and a verification command. +Execute steps 1–8 in order, then step 9 (build + deploy + verify). + +**Rules for the executing agent:** + +- Do NOT edit or remove existing entries in the `MIGRATIONS` array in + `db.service.ts`. The three v2 migrations (`20260707_claim_fusion`, + `20260707_backfill_claims`, `20260708_materialize_claim_fusion`) have + not applied to the live DB yet, but leave them as-is — they run + cleanly on first boot. +- Do NOT delete the v1 `getNextVibeChunk` CTE or `vibe.routes.ts` in + this plan. The doc says v1 is deleted *when D ships and is + verified*. That's a follow-up, not this plan. +- After all code edits (steps 1–7), run `npx tsc --noEmit` and + `npx vitest run` from `backend/` before deploying. +- All file paths are relative to `/home/kami/apps/muzick/`. + +--- + +## Step 1 — claim_fusion MV refresh consumer (blocker) + +**Problem:** `claim_fusion` is a MATERIALIZED VIEW. It is populated at +creation time (migration) and never refreshed again. A trigger fires +`NOTIFY claim_fusion_changed` on claims changes, but nothing LISTENs. +Every generator and compat view reads the frozen MV — new claims are +invisible. + +**Fix:** Add a `refreshClaimFusion()` method to `DbService` and a +background interval in `app.ts` that calls it every 10 seconds. +`CONCURRENTLY` won't block reads. + +### 1a. Add method to `backend/src/services/db.service.ts` + +Insert this method immediately before the closing `}` of the class +(after `seedDefaultDiversityBudgets`, which ends at line 2062): + +```ts + + /** + * Refresh the claim_fusion materialised view. Called on a periodic + * timer so the graph's read path stays current with new claims. + * CONCURRENTLY requires the unique index (idx_claim_fusion_pk), + * which the 20260708_materialize_claim_fusion migration creates. + */ + async refreshClaimFusion(): Promise<void> { + try { + await this.pgClient.query('SELECT refresh_claim_fusion()'); + } catch (err) { + // Non-fatal: the MV may not exist yet on first boot before + // migrations run. Log and move on; the next tick will retry. + console.error('[DB] refresh_claim_fusion failed:', err); + } + } +``` + +The `oldString` to match for the edit (the end of +`seedDefaultDiversityBudgets` + the class closing brace): + +``` + for (const d of defaults) { + await this.upsertDiversityBudget({ + user_id: userId, + dimension: d.dimension, + budget_share: d.share, + horizon_min: d.horizon, + }); + } + } +} +``` + +Replace with the same block + the new method inserted before the +final `}`. + +### 1b. Start the refresh interval in `backend/src/app.ts` + +After the line `await dbService.runMigrations();` (line 53), add: + +```ts + + // Keep the claim_fusion materialised view fresh. The trigger on + // `claims` fires NOTIFY on every change; rather than maintain a + // LISTEN consumer (separate long-lived connection), we refresh on a + // short interval. 10s staleness is well below any user-facing + // latency for a homelab music player. + const FUSION_REFRESH_MS = 10_000; + const fusionTimer = setInterval(() => { + dbService.refreshClaimFusion().catch(() => {}); + }, FUSION_REFRESH_MS); +``` + +Then in the `onClose` hook (around line 128), add `clearInterval` for +the new timer. Find: + +```ts + fastify.addHook('onClose', async () => { + try { + await pgClient.end(); +``` + +Insert before `await pgClient.end();`: + +```ts + clearInterval(fusionTimer); +``` + +**Verify:** `npx tsc --noEmit` in `backend/` — 0 errors. + +--- + +## Step 2 — daily belief decay + nightly forgotten derivation (blocker) + +**Problem:** `listener_beliefs.last_decayed_at` is set at insert and +never advanced. No decay job exists. This violates the core axiom +"everything decays unless reinforced" and reproduces the v1 failure +mode (heavily-played artists win forever). Also, the `forgotten` +profile is "derived nightly" per the doc but nothing populates it, so +the revival generator always returns empty. + +**Fix:** Add `decayBeliefs()` and `deriveForgottenProfile()` methods +to `DbService` and periodic intervals in `app.ts`. + +### 2a. Add decay method to `backend/src/services/db.service.ts` + +Insert after `refreshClaimFusion()` (the method added in step 1a): + +```ts + + /** + * Decay all listener beliefs whose last_decayed_at is older than 1 + * hour. Implements the decay formula from spec §B.4: + * value *= 0.5 ^ (elapsed / halflife) + * confidence *= 0.5 ^ (elapsed / halflife) + * Halflife is per-profile (longterm=365d, obsession=14d, discovery=30d, + * negative=180d, contextual=7d). The 'forgotten' profile is excluded + * — it is fully derived nightly by deriveForgottenProfile(), not + * decayed. + */ + async decayBeliefs(): Promise<number> { + const res = await this.pgClient.query(` + WITH halflives AS ( + SELECT profile, + CASE profile + WHEN 'longterm' THEN 365 * 86400 + WHEN 'obsession' THEN 14 * 86400 + WHEN 'discovery' THEN 30 * 86400 + WHEN 'negative' THEN 180 * 86400 + WHEN 'contextual' THEN 7 * 86400 + ELSE 30 * 86400 + END AS halflife_sec + ) + UPDATE listener_beliefs lb + SET value = GREATEST(-1.0, LEAST(1.0, lb.value * POWER(0.5, + EXTRACT(EPOCH FROM (NOW() - lb.last_decayed_at)) / h.halflife_sec))), + confidence = GREATEST(0, LEAST(1.0, lb.confidence * POWER(0.5, + EXTRACT(EPOCH FROM (NOW() - lb.last_decayed_at)) / h.halflife_sec))), + last_decayed_at = NOW() + FROM halflives h + WHERE lb.profile = h.profile + AND lb.profile <> 'forgotten' + AND lb.last_decayed_at < NOW() - INTERVAL '1 hour' + `); + return res.rowCount ?? 0; + } + + /** + * Derive the 'forgotten' profile nightly (spec §B.2): + * longterm affinity > 0.3 AND not reinforced in 90+ days. + * Wipes and repopulates — 'forgotten' is fully derived, not evidence-fed. + */ + async deriveForgottenProfile(): Promise<number> { + await this.pgClient.query( + `DELETE FROM listener_beliefs WHERE profile = 'forgotten'` + ); + const res = await this.pgClient.query(` + INSERT INTO listener_beliefs + (user_id, profile, entity_type, entity_id, dimension, value, + confidence, evidence_count, last_reinforced_at, last_decayed_at) + SELECT user_id, 'forgotten', entity_type, entity_id, dimension, + value, confidence, evidence_count, last_reinforced_at, NOW() + FROM listener_beliefs + WHERE profile = 'longterm' + AND dimension = 'affinity' + AND value > 0.3 + AND last_reinforced_at < NOW() - INTERVAL '90 days' + ON CONFLICT (user_id, profile, entity_type, entity_id, dimension) + DO UPDATE SET + value = EXCLUDED.value, + confidence = EXCLUDED.confidence, + evidence_count = EXCLUDED.evidence_count, + last_reinforced_at = EXCLUDED.last_reinforced_at + `); + return res.rowCount ?? 0; + } +``` + +### 2b. Start decay + forgotten intervals in `backend/src/app.ts` + +After the `fusionTimer` block added in step 1b, add: + +```ts + + // Daily belief decay (spec §B.4). Runs hourly; the SQL only touches + // beliefs whose last_decayed_at is >1h old, so frequent runs are safe. + const DECAY_INTERVAL_MS = 60 * 60 * 1000; + const decayTimer = setInterval(() => { + dbService.decayBeliefs().catch((e) => console.error('[DB] belief decay failed:', e)); + }, DECAY_INTERVAL_MS); + + // Nightly 'forgotten' profile derivation (spec §B.2). + const FORGOTTEN_INTERVAL_MS = 24 * 60 * 60 * 1000; + const forgottenTimer = setInterval(() => { + dbService.deriveForgottenProfile().catch((e) => + console.error('[DB] forgotten derivation failed:', e) + ); + }, FORGOTTEN_INTERVAL_MS); + + // Run both once at boot so the first session benefits. + dbService.decayBeliefs().catch(() => {}); + dbService.deriveForgottenProfile().catch(() => {}); +``` + +In the `onClose` hook, add (after `clearInterval(fusionTimer);`): + +```ts + clearInterval(decayTimer); + clearInterval(forgottenTimer); +``` + +**Verify:** `npx tsc --noEmit` in `backend/` — 0 errors. + +--- + +## Step 3 — fix MB spine writer generated-column bug + +**File:** `workers/src/mb-spine-writer.ts` + +**Problem:** `resolveArtist` (line 128) tries to INSERT into +`normalized_name`, which is `GENERATED ALWAYS AS normalize_artist(name) +STORED`. PostgreSQL rejects this: +`ERROR: cannot insert a non-DEFAULT value into column "normalized_name"`. +The stub-creation path is broken — the spine writer can only attach +claims to *existing* artists; any newly-credited artist is dropped. + +**Fix:** Remove `normalized_name` from the INSERT column list and the +`normalized` value from the params. The generated column auto-computes +from `name`. + +Find (lines 127–134): + +```ts + const result = await this.pgClient.query<{ id: string }>( + `INSERT INTO artists (name, canonical_name, sort_name, mbid, normalized_name) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (mbid) DO UPDATE SET name = EXCLUDED.name, updated_at = CURRENT_TIMESTAMP + RETURNING id`, + [creditName, artistName, sortName, mbid, normalized] + ); +``` + +Replace with: + +```ts + const result = await this.pgClient.query<{ id: string }>( + `INSERT INTO artists (name, canonical_name, sort_name, mbid) + VALUES ($1, $2, $3, $4) + ON CONFLICT (mbid) DO UPDATE SET name = EXCLUDED.name, updated_at = CURRENT_TIMESTAMP + RETURNING id`, + [creditName, artistName, sortName, mbid] + ); +``` + +**Verify:** `npx tsc --noEmit` in `workers/` — 0 errors. + +--- + +## Step 4 — fix dislikeTrack evidence-in-catch bug + +**File:** `backend/src/services/db.service.ts` + +**Problem:** `dislikeTrack` (line 712) writes the `hidden` evidence +row in the `catch` block — i.e. only when the transaction **fails**. +A successful dislike writes no negative evidence via this path. + +**Fix:** Move the evidence write out of the catch block to after the +try/catch, so it runs only on success. + +Find (lines 712–748): + +```ts + async dislikeTrack(userId: string, trackId: string): Promise<void> { + try { + await this.pgClient.query('BEGIN'); + + // Phase 1: hide the track in all active views + await this.pgClient.query( + "UPDATE tracks SET state = 'HIDDEN' WHERE id = $1 AND state = 'LIBRARY'", + [trackId] + ); + + // Phase 1: insert dislike row (idempotent — won't create duplicate) + await this.pgClient.query( + 'INSERT INTO dislikes (track_id) VALUES ($1) ON CONFLICT (track_id) DO NOTHING', + [trackId] + ); + + // Phase 1: log feedback signal for the Vibe learning loop + await this.pgClient.query( + "INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'disliked')", + [userId, trackId] + ); + + await this.pgClient.query('COMMIT'); + } catch (err) { + await this.pgClient.query('ROLLBACK'); + // Write evidence: hidden → negative profile + await this.recordEvidence({ + user_id: userId, + entity_type: 'track', + entity_id: trackId, + signal: 'hidden', + profile: 'negative', + weight: -0.60, + }); + throw err; + } + } +``` + +Replace with: + +```ts + async dislikeTrack(userId: string, trackId: string): Promise<void> { + try { + await this.pgClient.query('BEGIN'); + + // Phase 1: hide the track in all active views + await this.pgClient.query( + "UPDATE tracks SET state = 'HIDDEN' WHERE id = $1 AND state = 'LIBRARY'", + [trackId] + ); + + // Phase 1: insert dislike row (idempotent — won't create duplicate) + await this.pgClient.query( + 'INSERT INTO dislikes (track_id) VALUES ($1) ON CONFLICT (track_id) DO NOTHING', + [trackId] + ); + + // Phase 1: log feedback signal for the Vibe learning loop + await this.pgClient.query( + "INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'disliked')", + [userId, trackId] + ); + + await this.pgClient.query('COMMIT'); + } catch (err) { + await this.pgClient.query('ROLLBACK'); + throw err; + } + + // Write evidence: hidden → negative profile (only on success) + await this.recordEvidence({ + user_id: userId, + entity_type: 'track', + entity_id: trackId, + signal: 'hidden', + profile: 'negative', + weight: -0.60, + }); + } +``` + +**Verify:** `npx tsc --noEmit` in `backend/` — 0 errors. + +--- + +## Step 5 — fix hardDeleteTrack missing manual_deleted evidence + +**File:** `backend/src/services/db.service.ts` + +**Problem:** `hardDeleteTrack` (line 810) writes a legacy `feedback` +row but no evidence. The doc's strongest negative signal +(`manual_deleted → negative -0.90`) is missing. Permanent deletion has +no effect on listener beliefs. + +**Fix:** Add a `recordEvidence` call between the feedback insert and +the track DELETE. `evidence.entity_id` has no FK to `tracks`, so the +evidence row survives the deletion. + +Find (lines 813–821): + +```ts + // Log the permanent deletion feedback event first (before the track is gone) + await this.pgClient.query( + "INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'deleted_permanent')", + [userId, trackId] + ); + + // Delete DB record (ON DELETE CASCADE handles track_genre, play_history, + // feedback, track_audio_features, track_lyrics, recommendation_batch_track) + await this.pgClient.query('DELETE FROM tracks WHERE id = $1', [trackId]); +``` + +Replace with: + +```ts + // Log the permanent deletion feedback event first (before the track is gone) + await this.pgClient.query( + "INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'deleted_permanent')", + [userId, trackId] + ); + + // Write evidence: manual_deleted → negative profile (strongest negative + // signal, spec §B.3). entity_id has no FK to tracks, so the row survives. + await this.recordEvidence({ + user_id: userId, + entity_type: 'track', + entity_id: trackId, + signal: 'manual_deleted', + profile: 'negative', + weight: -0.90, + }); + + // Delete DB record (ON DELETE CASCADE handles track_genre, play_history, + // feedback, track_audio_features, track_lyrics, recommendation_batch_track) + await this.pgClient.query('DELETE FROM tracks WHERE id = $1', [trackId]); +``` + +**Verify:** `npx tsc --noEmit` in `backend/` — 0 errors. + +--- + +## Step 6 — switch session director artist reads to track_artists_v2 + +**File:** `backend/src/services/session-director.service.ts` + +**Problem:** The session director reads the legacy `track_artists` +table for artist fatigue, budget spend, repetition checks, seed +resolution, and recent-play artist lookup. Doc D.2 requires artist +fatigue to roll up via `alias_of` fusion (so DOOM / Madvillain / Viktor +Vaughn collapse into one artist). By bypassing `claim_fusion` / +`track_artists_v2`, the alias collapse cannot happen. + +**Fix:** Replace all `track_artists ta` → `track_artists_v2 ta` and +`track_artists ta3` → `track_artists_v2 ta3` in this file. The v2 view +has the same `track_id`, `artist_id`, `role` columns, so it's a +drop-in replacement. This depends on step 1 (MV refresh) being +deployed so the view has data. + +There are 11 occurrences across the file. Do two `replaceAll` edits: + +### 6a. Replace all `track_artists ta` with `track_artists_v2 ta` + +Use `replaceAll: true` on the string `track_artists ta` → +`track_artists_v2 ta`. This covers 10 occurrences (buildState, +computeFatigue, calcBudgetSpent artist, calcBudgetSpent new_artist, +checkRepetition, rankCandidates, buildPlan, replan, resolveSeedArtistId). + +### 6b. Replace `track_artists ta3` with `track_artists_v2 ta3` + +Use `replaceAll: true` on the string `track_artists ta3` → +`track_artists_v2 ta3`. This covers 1 occurrence in +`calcBudgetSpent`'s `new_artist` case. + +**Note:** Do step 6a first, then 6b. After 6a, the `ta3` occurrence +will still be `track_artists ta3` (it wasn't matched by `track_artists ta` +because that's a different string — `ta3` ≠ `ta`). So both +replacements are needed. + +**Verify:** +- `npx tsc --noEmit` in `backend/` — 0 errors. +- `grep -n "track_artists " backend/src/services/session-director.service.ts` + should return **zero** matches (all replaced). If any `track_artists ` + without `_v2` remain, fix them. + +--- + +## Step 7 — correct the session log + +**File:** `SESSION-07-07-2026.md` + +**Problem:** The session log overclaims: says "Full Stack" and +"replaces getNextVibeChunk" but nothing is deployed, v1 is intact, and +frontend is not wired. Also miscounts files (7 not 8) and says +noveltyGenerator was "skipped" when it's implemented. + +**Fix:** Make these edits: + +### 7a. Fix the headline (line 3) + +Find: +``` +## Implemented: v2 Recommendation Engine — Full Stack (Systems A–E + Phase 4) +``` +Replace with: +``` +## Scaffolded: v2 Recommendation Engine — code complete, not yet deployed (Systems A–E + Phase 4) +``` + +### 7b. Fix the file count (line 8) + +Find: +``` +### Files created (7 new) +``` +Replace with: +``` +### Files created (8 new) +``` + +And add a row to the table after the `image-enrichment.service.ts` row +(line 15). After: +``` +| `backend/src/services/image-enrichment.service.ts` | 105 | **Phase 4** — Image candidate pipeline | +``` +Add: +``` +| `workers/src/mb-spine-writer.ts` | 136 | **A** — MB artist-credit → claims writer (wired into enrichment.service.ts) | +``` + +### 7c. Fix the novelty generator claim (line 53) + +Find: +``` +- `noveltyGenerator`: skipped (no release_date column) +``` +Replace with: +``` +- `noveltyGenerator`: recent releases (≤60d) via same_scene_as/same_label_as/produced edges from trusted artists +``` + +### 7d. Fix the "replaces getNextVibeChunk" claim (line 56) + +Find: +``` +**System D — Session Director (replaces getNextVibeChunk)** +``` +Replace with: +``` +**System D — Session Director (runs alongside v1; getNextVibeChunk not yet deleted)** +``` + +### 7e. Fix the Verification section (lines 86–88) + +Find: +``` +### Verification +- `npx tsc --noEmit` — 0 errors +- No git repo — changes uncommitted +``` +Replace with: +``` +### Verification +- `npx tsc --noEmit` — 0 errors +- `npx vitest run` — 30/30 pass (mocked shape checks, not DB-state) +- No git repo — changes uncommitted +- NOT deployed: live backend container is pre-v2; `/api/v2/*` and `/api/graph/*` return 404; DB has zero v2 tables. See `docs/architecture/v2-fix-plan.md` for the fix + deploy plan. +``` + +### 7f. Fix the Next section (lines 89–92) + +Find: +``` +### Next +- Wire the v2 endpoint into the frontend Vibe page (replace v1 vibeService calls) +- Build yt-dlp worker for System E acquisition (download candidates) +- Runtime smoke-test after redeploy +``` +Replace with: +``` +### Next +- Execute `docs/architecture/v2-fix-plan.md` (MV refresh, decay job, bug fixes, deploy) +- After deploy + verify: wire the v2 endpoint into the frontend Vibe page (replace v1 vibeService calls) +- Build yt-dlp worker for System E acquisition (download candidates) +- After v2 is verified in production: delete v1 CTE (`getNextVibeChunk`), `vibe.routes.ts`, `feedback` table, `artist_similar` table per the doc's "Retiring v1" list +``` + +--- + +## Step 8 — run typecheck + tests before deploying + +```bash +cd /home/kami/apps/muzick/backend +npx tsc --noEmit +npx vitest run +``` + +Both must pass (0 ts errors, 30/30 tests). If any test fails, do not +deploy — re-read the relevant step and fix. + +Also typecheck the worker: + +```bash +cd /home/kami/apps/muzick/workers +npx tsc --noEmit +``` + +--- + +## Step 9 — build, deploy, and verify against the live DB + +### 9a. Rebuild the backend + worker images + +```bash +cd /home/kami/apps/muzick +docker compose build backend worker +docker compose up -d backend worker +``` + +Wait ~15 seconds for boot, then check the backend logs for migration +output: + +```bash +docker logs muzick-backend-1 --tail 80 2>&1 | grep -E "Migration|migration|ERROR|error" +``` + +You should see: +``` +[DB] Running migration: 20260707_claim_fusion +[DB] Migration applied: 20260707_claim_fusion +[DB] Running migration: 20260707_backfill_claims +[DB] Migration applied: 20260707_backfill_claims +[DB] Running migration: 20260708_materialize_claim_fusion +[DB] Migration applied: 20260708_materialize_claim_fusion +``` + +If any migration fails, read the error, fix the SQL in a NEW migration +(do not edit the failed one), rebuild, and redeploy. + +### 9b. Verify v2 tables + views exist and are populated + +```bash +docker exec muzick-db-1 psql -U user -d muzick -c " +SELECT 'migrations' AS check, COUNT(*) FROM schema_migrations +UNION ALL SELECT 'claims', COUNT(*) FROM claims +UNION ALL SELECT 'source_trust', COUNT(*) FROM source_trust +UNION ALL SELECT 'evidence', COUNT(*) FROM evidence +UNION ALL SELECT 'claim_fusion rows', COUNT(*) FROM claim_fusion +UNION ALL SELECT 'track_artists_v2 rows', COUNT(*) FROM track_artists_v2 +UNION ALL SELECT 'recording_mbid set', COUNT(*) FROM tracks WHERE recording_mbid IS NOT NULL; +" +``` + +Expected after first boot: +- `migrations` = 7 (4 old + 3 new) +- `source_trust` = 7 (seed rows) +- `claims` > 0 (backfilled from `track_artists` + `artist_similar`) +- `claim_fusion rows` > 0 (populated by the materialize migration) +- `track_artists_v2 rows` > 0 (view over claim_fusion) + +If `claims` = 0, the backfill migration found nothing — check that +`track_artists` and `artist_similar` have data in the live DB. + +### 9c. Verify the v2 endpoints are live + +```bash +curl -s http://localhost:3000/api/graph/sources | head -c 200 +echo +curl -s -o /dev/null -w "v2/state: HTTP %{http_code}\n" http://localhost:3000/api/v2/state +curl -s -o /dev/null -w "graph/sources: HTTP %{http_code}\n" http://localhost:3000/api/graph/sources +curl -s -o /dev/null -w "graph/summary: HTTP %{http_code}\n" http://localhost:3000/api/graph/summary +``` + +Expected: `v2/state: HTTP 200`, `graph/sources: HTTP 200`, +`graph/summary: HTTP 200`. + +### 9d. Smoke-test the v2 session flow + +```bash +# Start a v2 session (use any library track ID as seed) +SEED=$(docker exec muzick-db-1 psql -U user -d muzick -t -c "SELECT id FROM tracks WHERE state='LIBRARY' LIMIT 1" | tr -d ' \n') +echo "Seed track: $SEED" +curl -s -X POST http://localhost:3000/api/v2/vibe/start \ + -H 'Content-Type: application/json' \ + -H "x-user-id: 00000000-0000-0000-0000-000000000000" \ + -d "{\"seedTrackId\":\"$SEED\"}" | head -c 500 +echo +# Get the next track from the plan +curl -s http://localhost:3000/api/v2/vibe/next \ + -H "x-user-id: 00000000-0000-0000-0000-000000000000" | head -c 300 +echo +# Check the plan +curl -s http://localhost:3000/api/v2/vibe/plan \ + -H "x-user-id: 00000000-0000-0000-0000-000000000000" | head -c 300 +``` + +If `/v2/vibe/start` returns an empty plan `[]`, check the backend logs +for generator errors. The most likely cause is `claim_fusion` being +empty — verify step 9b showed `claim_fusion rows > 0`. + +### 9e. Verify evidence is written on a completed play + +```bash +USER="00000000-0000-0000-0000-000000000000" +TRACK=$(docker exec muzick-db-1 psql -U user -d muzick -t -c "SELECT id FROM tracks WHERE state='LIBRARY' LIMIT 1" | tr -d ' \n') + +# Before +docker exec muzick-db-1 psql -U user -d muzick -c "SELECT COUNT(*) AS evidence_before FROM evidence WHERE user_id='$USER'" + +# Record a completed play via the v2 feedback endpoint +curl -s -X POST http://localhost:3000/api/v2/vibe/feedback \ + -H 'Content-Type: application/json' \ + -H "x-user-id: $USER" \ + -d "{\"trackId\":\"$TRACK\",\"action\":\"completed\"}" + +# After +docker exec muzick-db-1 psql -U user -d muzick -c "SELECT COUNT(*) AS evidence_after, signal, profile, weight FROM evidence WHERE user_id='$USER' GROUP BY signal, profile, weight ORDER BY created_at DESC LIMIT 5" +``` + +Expected: `evidence_after > evidence_before`, and you should see a +`playback_completed` / `longterm` / `0.10` row. + +### 9f. Verify the MV refresh is running + +Wait 15 seconds after boot, then: + +```bash +docker logs muzick-backend-1 2>&1 | grep -i "refresh_claim_fusion" | tail -3 +``` + +You should see no errors (the method logs only on failure). If you see +repeated `refresh_claim_fusion failed` errors, the MV or refresh +function doesn't exist — re-check that the +`20260708_materialize_claim_fusion` migration applied. + +### 9g. Verify belief decay runs + +```bash +# Manually trigger decay and check it doesn't error +docker exec muzick-backend-1 node -e " +const { Client } = require('pg'); +const c = new Client({ connectionString: process.env.DATABASE_URL }); +(async () => { + await c.connect(); + const r = await c.query('SELECT decay_beliefs()'); + console.log('decay result:', r.rows); + await c.end(); +})().catch(e => { console.error('FAIL:', e.message); process.exit(1); }); +" 2>&1 || echo "decay_beliefs() not a SQL function — that's OK, the method runs the UPDATE directly" +``` + +This is a soft check — the `decayBeliefs()` method runs raw SQL, not a +stored function. The real verification is that `last_decayed_at` +advances after 1 hour. Check: + +```bash +docker exec muzick-db-1 psql -U user -d muzick -c " +SELECT user_id, profile, entity_type, last_decayed_at, + EXTRACT(EPOCH FROM (NOW() - last_decayed_at))/3600 AS hours_since_decay +FROM listener_beliefs +ORDER BY last_decayed_at DESC LIMIT 5; +" +``` + +After 1+ hours of uptime, `hours_since_decay` should be < 1 for +recently-decayed rows (the hourly job touched them). + +--- + +## Summary of what each step fixes + +| Step | Doc section | Problem | Fix | +|---|---|---|---| +| 1 | A.4 | `claim_fusion` MV never refreshed | 10s interval calls `refresh_claim_fusion()` | +| 2 | B.4, B.2 | No belief decay; `forgotten` never derived | Hourly decay job + 24h forgotten derivation | +| 3 | A.5 #1 | MB spine writer can't create new artists (generated column) | Drop `normalized_name` from INSERT | +| 4 | B.3 | `dislikeTrack` writes evidence only on failure | Move evidence write to success path | +| 5 | B.3 | `hardDeleteTrack` writes no `manual_deleted` evidence | Add `-0.90` evidence before track DELETE | +| 6 | D.2 | Director reads legacy `track_artists`, bypassing alias fusion | Switch to `track_artists_v2` | +| 7 | — | Session log overclaims | Correct the wording | +| 8 | — | Pre-deploy gate | tsc + vitest pass | +| 9 | A.7, B.6, D.11 | Not deployed; acceptance unverified | Build, deploy, verify against live DB | + +## What is NOT in this plan (follow-ups, not blockers) + +- **MB spine writer album + artist-relation claims** — + `writeAlbumClaims` and `writeArtistRelationClaims` in + `mb-spine-writer.ts` are stubs (`console.log` + `return 0`). They + require new `MusicBrainzClient` methods (release-group artist-credit, + artist-relations ARs). Not a blocker for first deploy; the recording- + claim writer is the critical path. Implement as a follow-up. +- **Frontend wiring** — the frontend still calls `/api/vibe/*` (v1). + After v2 is verified in production, wire `/api/v2/vibe/*` into + `frontend/src/services/vibeService.ts` and the Vibe page. +- **v1 deletion** — `getNextVibeChunk`, `vibe.routes.ts`, the + `feedback` table, `artist_similar` table, `genre.parent_id` are all + still present. The doc says delete them when D ships and is + verified. That's a separate, careful release after v2 is confirmed + good in production. +- **LISTEN-based MV refresh** — the 10s interval in step 1 is the + simple, foolproof approach. Upgrading to a `LISTEN`/`NOTIFY` consumer + (immediate refresh on claim change) is a follow-up if 10s staleness + ever becomes a problem. diff --git a/docs/plans/2026-06-08-ui-overhaul.md b/docs/plans/2026-06-08-ui-overhaul.md new file mode 100644 index 0000000..66e3d90 --- /dev/null +++ b/docs/plans/2026-06-08-ui-overhaul.md @@ -0,0 +1,1706 @@ +# UI Overhaul — Implementation Plan +**Date:** 2026-06-08 +**Phases:** 1–4 per `docs/ui-rework.md` +**Stack:** React 18 · TanStack Router · Zustand · react-query · Tailwind CSS 3 · lucide-react + +--- + +## File map + +| Action | Path | +|--------|------| +| **Modify** | `frontend/tailwind.config.js` | +| **Modify** | `frontend/src/index.css` | +| **Modify** | `frontend/src/lib/theme.ts` | +| **Modify** | `frontend/src/router.tsx` | +| **Modify** | `frontend/src/types.ts` | +| **Modify** | `frontend/src/pages/Home.tsx` | +| **Modify** | `frontend/src/pages/Tracks.tsx` | +| **Modify** | `frontend/src/pages/Artists.tsx` | +| **Modify** | `frontend/src/pages/ArtistDetail.tsx` | +| **Modify** | `frontend/src/pages/Albums.tsx` | +| **Modify** | `frontend/src/pages/AlbumDetail.tsx` | +| **Modify** | `frontend/src/pages/Genres.tsx` | +| **Modify** | `frontend/src/pages/Discover.tsx` | +| **Modify** | `frontend/src/pages/Vibe.tsx` | +| **Modify** | `frontend/src/pages/Search.tsx` | +| **Modify** | `frontend/src/pages/Quarantine.tsx` | +| **Modify** | `frontend/src/pages/Settings.tsx` | +| **Create** | `frontend/src/components/AppShell.tsx` | +| **Create** | `frontend/src/components/NavRail.tsx` | +| **Create** | `frontend/src/components/TopBar.tsx` | +| **Create** | `frontend/src/components/PlaybackBar.tsx` | +| **Create** | `frontend/src/components/NowPlayingPanel.tsx` | +| **Create** | `frontend/src/components/Artwork.tsx` | +| **Create** | `frontend/src/components/MediaCard.tsx` | +| **Create** | `frontend/src/components/ShelfRow.tsx` | +| **Create** | `frontend/src/components/TrackRow.tsx` | +| **Create** | `frontend/src/services/quarantineService.ts` | +| **Delete** | `frontend/src/components/Layout.tsx` | +| **Delete** | `frontend/src/components/NowPlayingBar.tsx` | +| **Delete** | `frontend/src/pages/LibraryTrackRow.tsx` | + +--- + +## Task 1 — Expand design tokens + +**Goal:** Add 8 new CSS vars, wire every token into `tailwind.config.js` as semantic color keys, update all 4 existing theme presets + add a Default(Purple) preset. + +**Files:** `frontend/tailwind.config.js`, `frontend/src/index.css`, `frontend/src/lib/theme.ts` + +**Steps:** + +1. Replace `frontend/tailwind.config.js`: +```js +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + theme: { + extend: { + colors: { + background: 'var(--bg)', + elevated: 'var(--bg-elevated)', + surface: 'var(--surface)', + 'surface-h':'var(--surface-hover)', + line: 'var(--border)', + primary: 'var(--text)', + muted: 'var(--text-muted)', + accent: 'var(--accent)', + 'accent-h': 'var(--accent-hover)', + 'on-accent':'var(--on-accent)', + 'grad-a': 'var(--card-grad-a)', + 'grad-b': 'var(--card-grad-b)', + }, + }, + }, + plugins: [], +}; +``` + +2. Replace the `:root` block in `frontend/src/index.css`: +```css +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --bg: #000000; + --bg-elevated: #111113; + --surface: #18181b; + --surface-hover: #27272a; + --border: #3f3f46; + --text: #ffffff; + --text-muted: #a1a1aa; + --accent: #3b82f6; + --accent-hover: #2563eb; + --on-accent: #ffffff; + --card-grad-a: #1e293b; + --card-grad-b: #0f172a; +} + +body { + margin: 0; + padding: 0; + background-color: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; +} +``` + +3. Replace `frontend/src/lib/theme.ts` — expand every preset's `vars` to include all 12 tokens; add a "Default (Purple)" preset: +```ts +export interface ThemePreset { + id: string; + name: string; + vars: Record<string, string>; + swatch: string; +} + +export const THEMES: ThemePreset[] = [ + { + id: 'purple', + name: 'Default (Purple)', + swatch: '#1e1b4b', + vars: { + '--bg': '#0d0b1a', '--bg-elevated': '#13102a', '--surface': '#1e1b4b', + '--surface-hover': '#2d2a5e', '--border': '#4c1d95', + '--text': '#ede9fe', '--text-muted': '#a78bfa', + '--accent': '#7c3aed', '--accent-hover': '#6d28d9', '--on-accent': '#ffffff', + '--card-grad-a': '#1e1b4b', '--card-grad-b': '#0d0b1a', + }, + }, + { + id: 'dark', + name: 'Dark', + swatch: '#18181b', + vars: { + '--bg': '#000000', '--bg-elevated': '#111113', '--surface': '#18181b', + '--surface-hover': '#27272a', '--border': '#3f3f46', + '--text': '#ffffff', '--text-muted': '#a1a1aa', + '--accent': '#3b82f6', '--accent-hover': '#2563eb', '--on-accent': '#ffffff', + '--card-grad-a': '#1e293b', '--card-grad-b': '#0f172a', + }, + }, + { + id: 'midnight', + name: 'Midnight', + swatch: '#0f172a', + vars: { + '--bg': '#020617', '--bg-elevated': '#0a1120', '--surface': '#0f172a', + '--surface-hover': '#1e293b', '--border': '#334155', + '--text': '#e2e8f0', '--text-muted': '#94a3b8', + '--accent': '#6366f1', '--accent-hover': '#4f46e5', '--on-accent': '#ffffff', + '--card-grad-a': '#1e1b4b', '--card-grad-b': '#020617', + }, + }, + { + id: 'forest', + name: 'Forest', + swatch: '#0c1f17', + vars: { + '--bg': '#03120c', '--bg-elevated': '#071a10', '--surface': '#0c1f17', + '--surface-hover': '#163024', '--border': '#1f4a33', + '--text': '#e7f5ee', '--text-muted': '#86efac', + '--accent': '#10b981', '--accent-hover': '#059669', '--on-accent': '#ffffff', + '--card-grad-a': '#0c1f17', '--card-grad-b': '#03120c', + }, + }, + { + id: 'plum', + name: 'Plum', + swatch: '#1e1029', + vars: { + '--bg': '#100619', '--bg-elevated': '#180924', '--surface': '#1e1029', + '--surface-hover': '#2d1a3d', '--border': '#5b2d7a', + '--text': '#f3e8ff', '--text-muted': '#d8b4fe', + '--accent': '#a855f7', '--accent-hover': '#9333ea', '--on-accent': '#ffffff', + '--card-grad-a': '#1e1029', '--card-grad-b': '#100619', + }, + }, +]; + +export const DEFAULT_THEME_ID = 'purple'; + +export const STORAGE_KEYS = { + theme: 'muzick.settings.theme', + volume: 'muzick.settings.volume', +} as const; + +export function applyTheme(theme: ThemePreset): void { + const root = document.documentElement; + for (const [key, value] of Object.entries(theme.vars)) { + root.style.setProperty(key, value); + } +} + +export function readStoredThemeId(): string { + try { + const stored = localStorage.getItem(STORAGE_KEYS.theme); + if (stored && THEMES.some((t) => t.id === stored)) return stored; + } catch { /* unavailable */ } + return DEFAULT_THEME_ID; +} + +export function initTheme(): void { + const id = readStoredThemeId(); + const theme = THEMES.find((t) => t.id === id) ?? THEMES[0]; + applyTheme(theme); +} + +export function readStoredVolume(fallback: number): number { + try { + const stored = localStorage.getItem(STORAGE_KEYS.volume); + if (stored !== null) { + const parsed = Number(stored); + if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 1) return parsed; + } + } catch { /* ignore */ } + return fallback; +} +``` + +**Acceptance criteria:** `npm run typecheck` in `frontend/` passes; no TS errors in theme.ts. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 2 — Artwork component + +**Goal:** A reusable `<Artwork>` that renders a deterministic gradient placeholder derived from a seed string (title/artist), with an optional `src` URL override. + +**Files:** `frontend/src/components/Artwork.tsx` (create) + +**Steps:** + +1. Create `frontend/src/components/Artwork.tsx`: +```tsx +interface ArtworkProps { + seed: string; + src?: string | null; + className?: string; + rounded?: 'sm' | 'md' | 'lg' | 'xl' | 'full'; +} + +function hueFromString(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; + return Math.abs(h) % 360; +} + +export function Artwork({ seed, src, className = '', rounded = 'md' }: ArtworkProps) { + const hue = hueFromString(seed); + const gradient = `linear-gradient(135deg, hsl(${hue},45%,22%), hsl(${(hue + 60) % 360},35%,12%))`; + const r = { sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg', xl: 'rounded-xl', full: 'rounded-full' }[rounded]; + + if (src) { + return <img src={src} alt={seed} className={`object-cover ${r} ${className}`} />; + } + return <div className={`${r} ${className}`} style={{ background: gradient }} />; +} +``` + +**Acceptance criteria:** Component renders with a gradient when no `src` given; renders an `<img>` when `src` is provided. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 3 — TrackRow component + +**Goal:** A single reusable `<TrackRow>` that replaces `LibraryTrackRow`, uses semantic token classes, and exposes an optional `onDislike` callback (so callers handle invalidation). + +**Files:** `frontend/src/components/TrackRow.tsx` (create) + +**Steps:** + +1. Create `frontend/src/components/TrackRow.tsx`: +```tsx +import { Play, Pause, Heart, ThumbsDown, Music } from 'lucide-react'; +import type { Track } from '../types'; +import { usePlaybackStore } from '../store/usePlaybackStore'; +import { favoritesService } from '../services/favoritesService'; +import { Artwork } from './Artwork'; + +export function formatDuration(seconds?: number | null): string { + if (!seconds || seconds < 0 || !Number.isFinite(seconds)) return '0:00'; + const total = Math.floor(seconds); + return `${Math.floor(total / 60)}:${(total % 60).toString().padStart(2, '0')}`; +} + +interface TrackRowProps { + track: Track; + queue: Track[]; + index: number; + showActions?: boolean; + trackNumber?: number; + onDislike?: (trackId: string) => void; +} + +export function TrackRow({ track, queue, index, showActions = true, trackNumber, onDislike }: TrackRowProps) { + const { setQueue, playTrack, play, pause, currentTrack, isPlaying } = usePlaybackStore(); + const isCurrent = currentTrack?.id === track.id; + + const handlePlay = () => { + if (isCurrent) { isPlaying ? pause() : play(); return; } + setQueue(queue.slice(index)); + playTrack(track); + }; + + const handleFavorite = (e: React.MouseEvent) => { + e.stopPropagation(); + void favoritesService.add(track.id).catch(() => undefined); + }; + + const handleDislike = (e: React.MouseEvent) => { + e.stopPropagation(); + void favoritesService.dislike(track.id).catch(() => undefined); + onDislike?.(track.id); + }; + + return ( + <div + onClick={handlePlay} + className={`group flex w-full cursor-pointer items-center gap-3 rounded-lg border p-2.5 transition-colors ${ + isCurrent + ? 'border-accent/60 bg-accent/10' + : 'border-line bg-surface/50 hover:border-line hover:bg-surface-h' + }`} + > + <div className="relative flex h-10 w-10 flex-none items-center justify-center rounded overflow-hidden"> + <Artwork seed={`${track.title} ${track.artist}`} className="absolute inset-0 w-full h-full" /> + {trackNumber !== undefined ? ( + <span className={`relative z-10 text-sm tabular-nums text-muted group-hover:opacity-0 ${isCurrent && isPlaying ? 'opacity-0' : ''}`}> + {trackNumber} + </span> + ) : ( + <Music size={18} className={`relative z-10 text-muted group-hover:opacity-0 ${isCurrent && isPlaying ? 'opacity-0' : ''}`} /> + )} + {isCurrent && isPlaying ? ( + <Pause size={18} className="absolute z-20 text-primary opacity-100" /> + ) : ( + <Play size={18} className="absolute z-20 text-primary opacity-0 group-hover:opacity-100" /> + )} + </div> + + <div className="min-w-0 flex-1"> + <div className={`truncate text-sm font-medium ${isCurrent ? 'text-accent' : 'text-primary'}`}> + {track.title || 'Untitled'} + </div> + <div className="truncate text-xs text-muted">{track.artist || 'Unknown artist'}</div> + </div> + + {showActions && ( + <div className="flex flex-none items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"> + <button onClick={handleFavorite} title="Favorite" className="rounded p-1.5 text-muted hover:bg-surface-h hover:text-pink-400"> + <Heart size={16} /> + </button> + <button onClick={handleDislike} title="Dislike" className="rounded p-1.5 text-muted hover:bg-surface-h hover:text-red-400"> + <ThumbsDown size={16} /> + </button> + </div> + )} + + <div className="flex-none text-xs tabular-nums text-muted">{formatDuration(track.duration)}</div> + </div> + ); +} +``` + +**Acceptance criteria:** Renders with semantic classes; `isCurrent` highlights with accent; `trackNumber` or icon shown; actions hidden until hover. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 4 — PlaybackBar + +**Goal:** Full-width bottom transport bar (replaces `NowPlayingBar`): artwork thumbnail + title/artist on left, controls + scrubber in center, volume + panel-toggle on right. + +**Files:** `frontend/src/components/PlaybackBar.tsx` (create) + +**Steps:** + +1. Create `frontend/src/components/PlaybackBar.tsx`: +```tsx +import { Play, Pause, SkipBack, SkipForward, Volume2, ListMusic } from 'lucide-react'; +import { usePlaybackStore } from '../store/usePlaybackStore'; +import { Artwork } from './Artwork'; +import { formatDuration } from './TrackRow'; + +interface PlaybackBarProps { + panelOpen: boolean; + onTogglePanel: () => void; +} + +export function PlaybackBar({ panelOpen, onTogglePanel }: PlaybackBarProps) { + const { currentTrack, isPlaying, position, duration, volume, play, pause, next, prev, setPosition, setVolume } = usePlaybackStore(); + + return ( + <div className="h-20 bg-elevated border-t border-line px-4 flex items-center gap-4 shrink-0"> + {/* Track info */} + <div className="flex items-center gap-3 w-56 min-w-0 shrink-0"> + {currentTrack ? ( + <> + <div className="w-12 h-12 flex-none rounded overflow-hidden"> + <Artwork seed={`${currentTrack.title} ${currentTrack.artist}`} className="w-full h-full" /> + </div> + <div className="min-w-0"> + <div className="text-sm font-semibold text-primary truncate">{currentTrack.title}</div> + <div className="text-xs text-muted truncate">{currentTrack.artist}</div> + </div> + </> + ) : ( + <div className="text-sm text-muted italic">Nothing playing</div> + )} + </div> + + {/* Controls + scrubber */} + <div className="flex-1 flex flex-col items-center gap-1"> + <div className="flex items-center gap-5"> + <button onClick={prev} className="text-muted hover:text-primary" aria-label="Previous"> + <SkipBack size={20} /> + </button> + <button + onClick={() => isPlaying ? pause() : play()} + disabled={!currentTrack} + className="w-9 h-9 rounded-full bg-accent hover:bg-accent-h flex items-center justify-center text-on-accent disabled:opacity-40 transition-colors" + aria-label={isPlaying ? 'Pause' : 'Play'} + > + {isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />} + </button> + <button onClick={next} className="text-muted hover:text-primary" aria-label="Next"> + <SkipForward size={20} /> + </button> + </div> + <div className="flex w-full max-w-lg items-center gap-2"> + <span className="text-xs text-muted w-9 text-right tabular-nums">{formatDuration(position)}</span> + <input + type="range" min={0} max={Math.max(duration, 0.1)} step={0.1} + value={Math.min(position, duration || 0)} + onChange={(e) => setPosition(Number(e.target.value))} + disabled={!currentTrack || duration <= 0} + className="flex-1 h-1 cursor-pointer accent-[var(--accent)]" + aria-label="Seek" + /> + <span className="text-xs text-muted w-9 tabular-nums">{formatDuration(duration)}</span> + </div> + </div> + + {/* Volume + panel toggle */} + <div className="flex items-center gap-3 w-48 justify-end shrink-0"> + <Volume2 size={18} className="text-muted flex-none" /> + <input + type="range" min={0} max={1} step={0.01} value={volume} + onChange={(e) => setVolume(Number(e.target.value))} + className="w-20 h-1 cursor-pointer accent-[var(--accent)]" + aria-label="Volume" + /> + <button + onClick={onTogglePanel} + className={`p-2 rounded-md transition-colors ${panelOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-primary'}`} + aria-label="Toggle queue panel" + title="Up Next" + > + <ListMusic size={18} /> + </button> + </div> + </div> + ); +} +``` + +**Acceptance criteria:** Play/pause button is a circle with accent fill; scrubber spans center; volume + panel toggle on right; disabled state when no track. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 5 — NowPlayingPanel + +**Goal:** Collapsible right panel — large artwork, track info, scrubber, transport, Up Next queue list. + +**Files:** `frontend/src/components/NowPlayingPanel.tsx` (create) + +**Steps:** + +1. Create `frontend/src/components/NowPlayingPanel.tsx`: +```tsx +import { X, Play, Pause, SkipBack, SkipForward, Music } from 'lucide-react'; +import { usePlaybackStore } from '../store/usePlaybackStore'; +import { Artwork } from './Artwork'; +import { formatDuration } from './TrackRow'; + +interface NowPlayingPanelProps { + onClose: () => void; +} + +export function NowPlayingPanel({ onClose }: NowPlayingPanelProps) { + const { currentTrack, queue, isPlaying, position, duration, play, pause, next, prev, setPosition, playTrack, setQueue } = usePlaybackStore(); + + const currentIdx = currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1; + const upNext = currentIdx >= 0 ? queue.slice(currentIdx + 1) : queue; + + return ( + <aside className="w-72 flex flex-col border-l border-line bg-elevated overflow-hidden shrink-0"> + <div className="flex items-center justify-between px-4 py-3 border-b border-line"> + <span className="text-sm font-semibold text-primary">Now Playing</span> + <button onClick={onClose} className="text-muted hover:text-primary p-1 rounded"> + <X size={16} /> + </button> + </div> + + <div className="p-4 space-y-4"> + <div className="aspect-square rounded-xl overflow-hidden"> + <Artwork + seed={currentTrack ? `${currentTrack.title} ${currentTrack.artist}` : 'empty'} + className="w-full h-full" + rounded="xl" + /> + </div> + + {currentTrack ? ( + <div className="text-center space-y-0.5"> + <div className="font-bold text-primary truncate">{currentTrack.title}</div> + <div className="text-sm text-muted truncate">{currentTrack.artist}</div> + </div> + ) : ( + <div className="text-center text-sm text-muted italic">No track playing</div> + )} + + <div className="space-y-1"> + <input + type="range" min={0} max={Math.max(duration, 0.1)} step={0.1} + value={Math.min(position, duration || 0)} + onChange={(e) => setPosition(Number(e.target.value))} + disabled={!currentTrack || duration <= 0} + className="w-full h-1 cursor-pointer accent-[var(--accent)]" + /> + <div className="flex justify-between text-xs text-muted tabular-nums"> + <span>{formatDuration(position)}</span> + <span>{formatDuration(duration)}</span> + </div> + </div> + + <div className="flex items-center justify-center gap-6"> + <button onClick={prev} className="text-muted hover:text-primary"><SkipBack size={20} /></button> + <button + onClick={() => isPlaying ? pause() : play()} + disabled={!currentTrack} + className="w-10 h-10 rounded-full bg-accent hover:bg-accent-h flex items-center justify-center text-on-accent disabled:opacity-40" + > + {isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />} + </button> + <button onClick={next} className="text-muted hover:text-primary"><SkipForward size={20} /></button> + </div> + </div> + + <div className="flex-1 overflow-y-auto border-t border-line"> + <div className="px-4 py-2 text-xs font-semibold text-muted uppercase tracking-wide"> + Up Next ({upNext.length}) + </div> + {upNext.length === 0 ? ( + <div className="px-4 pb-4 text-sm text-muted italic">Queue is empty.</div> + ) : ( + <ul> + {upNext.map((track, i) => ( + <li key={`${track.id}-${i}`}> + <button + onClick={() => { setQueue(upNext.slice(i)); playTrack(track); }} + className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-surface-h" + > + <div className="w-8 h-8 flex-none rounded overflow-hidden"> + <Artwork seed={`${track.title} ${track.artist}`} className="w-full h-full" /> + </div> + <div className="min-w-0 flex-1"> + <div className="truncate text-sm text-primary">{track.title}</div> + <div className="truncate text-xs text-muted">{track.artist}</div> + </div> + </button> + </li> + ))} + </ul> + )} + </div> + </aside> + ); +} +``` + +**Acceptance criteria:** Panel shows artwork, scrubber, transport, and Up Next list; `onClose` hides it. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 6 — NavRail + +**Goal:** Persistent left navigation column with two groups (Library, Personal), active state via accent, links to all routes. + +**Files:** `frontend/src/components/NavRail.tsx` (create) + +**Steps:** + +1. Create `frontend/src/components/NavRail.tsx`: +```tsx +import { Link } from '@tanstack/react-router'; +import { + Home, Music, Disc3, Users, Tags, Zap, Compass, + ShieldAlert, Settings, +} from 'lucide-react'; + +const NAV_GROUPS = [ + { + label: 'Library', + items: [ + { to: '/', icon: Home, label: 'Home' }, + { to: '/tracks', icon: Music, label: 'Songs' }, + { to: '/albums', icon: Disc3, label: 'Albums' }, + { to: '/artists', icon: Users, label: 'Artists' }, + { to: '/genres', icon: Tags, label: 'Genres' }, + { to: '/vibe', icon: Zap, label: 'Vibe' }, + { to: '/discover', icon: Compass, label: 'Discover' }, + ], + }, + { + label: 'Personal', + items: [ + { to: '/quarantine', icon: ShieldAlert, label: 'Quarantine' }, + { to: '/settings', icon: Settings, label: 'Settings' }, + ], + }, +] as const; + +const base = 'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors w-full'; +const inactive = 'text-muted hover:bg-surface-h hover:text-primary'; +const active = 'bg-accent/15 text-accent font-medium'; + +export function NavRail() { + return ( + <aside className="w-56 flex flex-col bg-surface border-r border-line shrink-0 overflow-y-auto"> + <div className="px-4 py-5"> + <span className="text-xl font-bold text-accent tracking-tight">Muzick</span> + </div> + <nav className="flex-1 px-3 space-y-5 pb-4"> + {NAV_GROUPS.map((group) => ( + <div key={group.label}> + <div className="px-3 mb-1.5 text-xs font-semibold uppercase tracking-wider text-muted/60"> + {group.label} + </div> + <ul className="space-y-0.5"> + {group.items.map(({ to, icon: Icon, label }) => ( + <li key={to}> + <Link + to={to} + activeOptions={{ exact: to === '/' }} + activeProps={{ className: `${base} ${active}` }} + inactiveProps={{ className: `${base} ${inactive}` }} + > + <Icon size={18} /> + {label} + </Link> + </li> + ))} + </ul> + </div> + ))} + </nav> + </aside> + ); +} +``` + +**Acceptance criteria:** Active route link has accent background; two labelled groups; logo at top. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 7 — TopBar + +**Goal:** Narrow top bar with logo gap (the NavRail handles branding) and a global search input that navigates to `/search?q=` on submit. + +**Files:** `frontend/src/components/TopBar.tsx` (create) + +**Steps:** + +1. Create `frontend/src/components/TopBar.tsx`: +```tsx +import { useState } from 'react'; +import { Search } from 'lucide-react'; +import { useNavigate } from '@tanstack/react-router'; + +export function TopBar() { + const [q, setQ] = useState(''); + const navigate = useNavigate(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (q.trim()) void navigate({ to: '/search', search: { q: q.trim() } as any }); + }; + + return ( + <header className="h-14 bg-elevated border-b border-line flex items-center px-4 gap-4 shrink-0"> + <div className="w-56 shrink-0" /> {/* aligns with NavRail width */} + <form onSubmit={handleSubmit} className="flex-1 max-w-xl"> + <div className="relative"> + <Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" /> + <input + type="search" + value={q} + onChange={(e) => setQ(e.target.value)} + placeholder="Search music… (Enter)" + className="w-full bg-surface border border-line rounded-lg pl-9 pr-4 py-1.5 text-sm text-primary placeholder:text-muted outline-none focus:border-accent transition-colors" + /> + </div> + </form> + </header> + ); +} +``` + +**Acceptance criteria:** Submitting the form navigates to `/search` with a `q` param; input styled with surface/border tokens. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 8 — AppShell + router update + +**Goal:** Replace `Layout` with `AppShell` (3-pane grid), wire `NowPlayingPanel` (starts collapsed), `PlaybackBar`, `NavRail`, `TopBar`, and `AudioEngine`. Update `router.tsx` to use `AppShell`. + +**Files:** `frontend/src/components/AppShell.tsx` (create), `frontend/src/router.tsx` (modify) + +**Steps:** + +1. Create `frontend/src/components/AppShell.tsx`: +```tsx +import { useState } from 'react'; +import { Outlet } from '@tanstack/react-router'; +import { AudioEngine } from './AudioEngine'; +import { NavRail } from './NavRail'; +import { TopBar } from './TopBar'; +import { PlaybackBar } from './PlaybackBar'; +import { NowPlayingPanel } from './NowPlayingPanel'; + +export default function AppShell() { + const [panelOpen, setPanelOpen] = useState(false); + + return ( + <div className="flex flex-col h-screen bg-background text-primary overflow-hidden"> + <TopBar /> + <div className="flex flex-1 overflow-hidden"> + <NavRail /> + <main className="flex-1 overflow-y-auto p-6"> + <Outlet /> + </main> + {panelOpen && <NowPlayingPanel onClose={() => setPanelOpen(false)} />} + </div> + <PlaybackBar panelOpen={panelOpen} onTogglePanel={() => setPanelOpen((o) => !o)} /> + <AudioEngine /> + </div> + ); +} +``` + +2. In `frontend/src/router.tsx`, replace `import Layout` and its usage: +```tsx +// replace: +import Layout from './components/Layout'; +// with: +import AppShell from './components/AppShell'; + +// replace in rootRoute component: +// <Layout><Outlet /></Layout> +// with: +// <AppShell /> +// (AppShell renders <Outlet /> itself) +``` + +Full updated rootRoute component: +```tsx +export const rootRoute = createRootRoute({ + component: AppShell, +}); +``` + +**Acceptance criteria:** App renders the 3-pane layout; panel hidden by default; clicking the queue icon in PlaybackBar opens/closes the panel. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 9 — MediaCard + ShelfRow + +**Goal:** `<MediaCard>` is a square artwork card with hover-play overlay used in grids and carousels. `<ShelfRow>` is a horizontal scroll container with a title + optional "View all" link. + +**Files:** `frontend/src/components/MediaCard.tsx` (create), `frontend/src/components/ShelfRow.tsx` (create) + +**Steps:** + +1. Create `frontend/src/components/MediaCard.tsx`: +```tsx +import { Play } from 'lucide-react'; +import { Artwork } from './Artwork'; + +interface MediaCardProps { + seed: string; + title: string; + subtitle?: string; + artSrc?: string | null; + onClick?: () => void; + href?: string; +} + +export function MediaCard({ seed, title, subtitle, artSrc, onClick }: MediaCardProps) { + return ( + <button + onClick={onClick} + className="group flex flex-col gap-2 text-left w-full bg-surface hover:bg-surface-h border border-line rounded-xl p-3 transition-colors" + > + <div className="relative aspect-square rounded-lg overflow-hidden w-full"> + <Artwork seed={seed} src={artSrc} className="w-full h-full" rounded="lg" /> + <div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity"> + <div className="w-10 h-10 rounded-full bg-accent flex items-center justify-center shadow-lg"> + <Play size={18} fill="white" className="text-on-accent ml-0.5" /> + </div> + </div> + </div> + <div className="min-w-0"> + <div className="truncate text-sm font-semibold text-primary">{title}</div> + {subtitle && <div className="truncate text-xs text-muted mt-0.5">{subtitle}</div>} + </div> + </button> + ); +} +``` + +2. Create `frontend/src/components/ShelfRow.tsx`: +```tsx +import { Link } from '@tanstack/react-router'; +import { ChevronRight } from 'lucide-react'; + +interface ShelfRowProps { + title: string; + viewAllTo?: string; + children: React.ReactNode; +} + +export function ShelfRow({ title, viewAllTo, children }: ShelfRowProps) { + return ( + <section className="space-y-3"> + <div className="flex items-center justify-between"> + <h2 className="text-lg font-bold text-primary">{title}</h2> + {viewAllTo && ( + <Link to={viewAllTo} className="flex items-center gap-0.5 text-xs text-muted hover:text-accent transition-colors"> + View all <ChevronRight size={14} /> + </Link> + )} + </div> + <div className="flex gap-4 overflow-x-auto pb-2 scrollbar-hide"> + {children} + </div> + </section> + ); +} +``` + +**Acceptance criteria:** MediaCard shows gradient artwork with play overlay on hover; ShelfRow scrolls horizontally and shows "View all" link when `viewAllTo` given. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 10 — Rework Home.tsx + +**Goal:** Replace the current two-section Home with Quick Access cards row + three shelf rows (Continue Listening, Recently Added, Most Played). + +**Files:** `frontend/src/pages/Home.tsx` (modify) + +**Steps:** + +1. Replace `frontend/src/pages/Home.tsx` entirely: +```tsx +import { useQuery } from '@tanstack/react-query'; +import { Clock, Heart, Star, PlusCircle } from 'lucide-react'; +import { ShelfRow } from '../components/ShelfRow'; +import { MediaCard } from '../components/MediaCard'; +import { historyService } from '../services/historyService'; +import { trackService } from '../services/trackService'; +import { usePlaybackStore } from '../store/usePlaybackStore'; +import type { HistoryEntry, Track } from '../types'; + +interface QuickCard { + label: string; + icon: React.ReactNode; + to: string; + gradient: string; +} + +const QUICK: QuickCard[] = [ + { label: 'Favorites', icon: <Heart size={20} />, to: '/tracks', gradient: 'from-pink-900/80 to-rose-950/80' }, + { label: 'Recently Added', icon: <PlusCircle size={20} />, to: '/tracks', gradient: 'from-blue-900/80 to-indigo-950/80' }, + { label: 'Most Played', icon: <Star size={20} />, to: '/tracks', gradient: 'from-amber-900/80 to-orange-950/80' }, + { label: 'History', icon: <Clock size={20} />, to: '/tracks', gradient: 'from-emerald-900/80 to-teal-950/80' }, +]; + +export default function Home() { + const { setQueue, playTrack } = usePlaybackStore(); + + const history = useQuery<HistoryEntry[]>({ + queryKey: ['history'], + queryFn: () => historyService.list(), + }); + + const recentlyAdded = useQuery<Track[]>({ + queryKey: ['recently-added'], + queryFn: () => trackService.listTracks({ limit: 20 }), + select: (tracks) => [...tracks].sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0)).slice(0, 12), + }); + + const mostPlayed = useQuery<Track[]>({ + queryKey: ['most-played'], + queryFn: () => trackService.listTracks({ limit: 12, sort_by: 'play_count', order: 'DESC' }), + }); + + const playFrom = (list: Track[], index: number) => { + setQueue(list.slice(index)); + playTrack(list[index]); + }; + + const historyTracks: Track[] = (history.data ?? []).slice(0, 12); + + return ( + <div className="space-y-8 max-w-5xl"> + <div> + <h1 className="text-3xl font-bold text-primary">Good listening</h1> + <p className="text-muted mt-1">Your music, your way.</p> + </div> + + {/* Quick access */} + <section> + <div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> + {QUICK.map((card) => ( + <div + key={card.label} + className={`flex items-center gap-3 rounded-lg bg-gradient-to-br ${card.gradient} border border-line/40 px-4 py-3 cursor-pointer hover:opacity-90 transition-opacity`} + > + <span className="text-primary/70">{card.icon}</span> + <span className="text-sm font-semibold text-primary">{card.label}</span> + </div> + ))} + </div> + </section> + + <ShelfRow title="Continue Listening" viewAllTo="/tracks"> + {history.isLoading ? ( + <p className="text-sm text-muted py-4">Loading…</p> + ) : historyTracks.length === 0 ? ( + <p className="text-sm text-muted py-4">Nothing played yet.</p> + ) : ( + historyTracks.map((track, i) => ( + <div key={`${track.id}-${i}`} className="w-36 shrink-0"> + <MediaCard + seed={`${track.title} ${track.artist}`} + title={track.title} + subtitle={track.artist} + onClick={() => playFrom(historyTracks, i)} + /> + </div> + )) + )} + </ShelfRow> + + <ShelfRow title="Recently Added" viewAllTo="/tracks"> + {recentlyAdded.isLoading ? ( + <p className="text-sm text-muted py-4">Loading…</p> + ) : (recentlyAdded.data ?? []).length === 0 ? ( + <p className="text-sm text-muted py-4">No tracks yet.</p> + ) : ( + (recentlyAdded.data ?? []).map((track, i) => ( + <div key={track.id} className="w-36 shrink-0"> + <MediaCard + seed={`${track.title} ${track.artist}`} + title={track.title} + subtitle={track.artist} + onClick={() => playFrom(recentlyAdded.data!, i)} + /> + </div> + )) + )} + </ShelfRow> + + <ShelfRow title="Most Played" viewAllTo="/tracks"> + {mostPlayed.isLoading ? ( + <p className="text-sm text-muted py-4">Loading…</p> + ) : (mostPlayed.data ?? []).length === 0 ? ( + <p className="text-sm text-muted py-4">No tracks yet.</p> + ) : ( + (mostPlayed.data ?? []).map((track, i) => ( + <div key={track.id} className="w-36 shrink-0"> + <MediaCard + seed={`${track.title} ${track.artist}`} + title={track.title} + subtitle={`${track.play_count} plays`} + onClick={() => playFrom(mostPlayed.data!, i)} + /> + </div> + )) + )} + </ShelfRow> + </div> + ); +} +``` + +**Acceptance criteria:** Page shows 4 quick-access gradient cards + 3 horizontal shelves; clicking a media card plays from that position. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 11 — Restyle library pages (Tracks, Artists, ArtistDetail, Albums, AlbumDetail, Genres) + +**Goal:** Replace all hard-coded `zinc-*` classes with semantic tokens; replace `LibraryTrackRow` with `TrackRow`; use `MediaCard` / `Artwork` in grid views. + +**Files:** `Tracks.tsx`, `Artists.tsx`, `ArtistDetail.tsx`, `Albums.tsx`, `AlbumDetail.tsx`, `Genres.tsx` (all modify) + +**Steps:** + +1. **`Tracks.tsx`** — swap `LibraryTrackRow` for `TrackRow`; restyle pagination buttons: +```tsx +import { useState } from 'react'; +import { useQuery, keepPreviousData } from '@tanstack/react-query'; +import { Music, ChevronLeft, ChevronRight } from 'lucide-react'; +import { trackService } from '../services/trackService'; +import { TrackRow } from '../components/TrackRow'; +import type { Track } from '../types'; + +const PAGE_SIZE = 50; + +export default function Tracks() { + const [page, setPage] = useState(0); + const { data, isLoading, isError, isPlaceholderData } = useQuery<Track[]>({ + queryKey: ['tracks', page], + queryFn: () => trackService.listTracks({ limit: PAGE_SIZE, offset: page * PAGE_SIZE, sort_by: 'title', order: 'ASC' }), + placeholderData: keepPreviousData, + }); + const tracks = data ?? []; + const hasNext = tracks.length === PAGE_SIZE; + + return ( + <div className="space-y-6 max-w-3xl"> + <h1 className="flex items-center gap-3 text-3xl font-bold text-primary"> + <Music size={28} className="text-accent" /> Songs + </h1> + {isLoading ? <p className="text-sm text-muted">Loading…</p> + : isError ? <p className="text-sm text-muted">Couldn't load tracks.</p> + : tracks.length === 0 ? <p className="text-sm text-muted">{page === 0 ? 'No tracks yet.' : 'No more tracks.'}</p> + : <div className="space-y-1">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>} + <div className="flex items-center justify-between pt-2"> + <button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0 || isPlaceholderData} + className="inline-flex items-center gap-1 rounded-lg border border-line px-3 py-1.5 text-sm text-primary hover:bg-surface-h disabled:opacity-40"> + <ChevronLeft size={16} /> Prev + </button> + <span className="text-sm text-muted">Page {page + 1}</span> + <button onClick={() => setPage((p) => p + 1)} disabled={!hasNext || isPlaceholderData} + className="inline-flex items-center gap-1 rounded-lg border border-line px-3 py-1.5 text-sm text-primary hover:bg-surface-h disabled:opacity-40"> + Next <ChevronRight size={16} /> + </button> + </div> + </div> + ); +} +``` + +2. **`Artists.tsx`** — replace `zinc-*` with tokens; use `Artwork` for avatar: +```tsx +import { useQuery } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { Users } from 'lucide-react'; +import { artistService } from '../services/artistService'; +import { Artwork } from '../components/Artwork'; +import type { Artist } from '../types'; + +export default function Artists() { + const { data, isLoading, isError } = useQuery<Artist[]>({ + queryKey: ['artists'], + queryFn: () => artistService.listArtists(), + }); + + return ( + <div className="space-y-6"> + <h1 className="flex items-center gap-3 text-3xl font-bold text-primary"> + <Users size={28} className="text-accent" /> Artists + </h1> + {isLoading ? <p className="text-sm text-muted">Loading…</p> + : isError ? <p className="text-sm text-muted">Couldn't load artists.</p> + : !data?.length ? <p className="text-sm text-muted">No artists yet.</p> + : ( + <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"> + {data.map((artist) => ( + <Link key={artist.id} to="/artists/$artistId" params={{ artistId: artist.id }} + className="group flex flex-col items-center gap-3 rounded-xl border border-line bg-surface p-4 hover:bg-surface-h transition-colors"> + <div className="w-24 h-24 rounded-full overflow-hidden"> + <Artwork seed={artist.name} src={artist.image_path} className="w-full h-full" rounded="full" /> + </div> + <div className="text-sm font-medium text-primary truncate w-full text-center">{artist.name}</div> + </Link> + ))} + </div> + )} + </div> + ); +} +``` + +3. **`ArtistDetail.tsx`** — tokens; use `Artwork` for artist and album cards: +```tsx +import { useQuery } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { ArrowLeft } from 'lucide-react'; +import { artistDetailRoute } from '../router'; +import { artistService } from '../services/artistService'; +import { Artwork } from '../components/Artwork'; +import type { ArtistWithAlbums } from '../types'; + +export default function ArtistDetail() { + const { artistId } = artistDetailRoute.useParams(); + const { data, isLoading, isError } = useQuery<ArtistWithAlbums>({ + queryKey: ['artist', artistId], + queryFn: () => artistService.getArtist(artistId), + }); + if (isLoading) return <p className="text-sm text-muted">Loading…</p>; + if (isError || !data) return <p className="text-sm text-muted">Couldn't load artist.</p>; + const albums = data.albums ?? []; + return ( + <div className="space-y-8 max-w-4xl"> + <Link to="/artists" className="inline-flex items-center gap-1 text-sm text-muted hover:text-primary"> + <ArrowLeft size={16} /> Artists + </Link> + <div className="flex items-center gap-5"> + <div className="w-28 h-28 flex-none rounded-full overflow-hidden"> + <Artwork seed={data.name} src={data.image_path} className="w-full h-full" rounded="full" /> + </div> + <div> + <h1 className="text-4xl font-bold text-primary">{data.name}</h1> + <p className="text-sm text-muted mt-1">{albums.length} {albums.length === 1 ? 'album' : 'albums'}</p> + </div> + </div> + <section className="space-y-4"> + <h2 className="text-xl font-semibold text-primary">Albums</h2> + {albums.length === 0 ? <p className="text-sm text-muted">No albums.</p> : ( + <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"> + {albums.map((album) => ( + <Link key={album.id} to="/albums/$albumId" params={{ albumId: album.id }} + className="group flex flex-col gap-2 rounded-xl border border-line bg-surface p-3 hover:bg-surface-h transition-colors"> + <div className="aspect-square rounded-lg overflow-hidden"> + <Artwork seed={`${album.title} ${data.name}`} className="w-full h-full" rounded="lg" /> + </div> + <div> + <div className="truncate text-sm font-medium text-primary">{album.title}</div> + {album.year && <div className="text-xs text-muted">{album.year}</div>} + </div> + </Link> + ))} + </div> + )} + </section> + </div> + ); +} +``` + +4. **`Albums.tsx`** — tokens + `Artwork`: +```tsx +import { useQuery } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { Disc3 } from 'lucide-react'; +import { albumService } from '../services/albumService'; +import { Artwork } from '../components/Artwork'; +import type { Album } from '../types'; + +export default function Albums() { + const { data, isLoading, isError } = useQuery<Album[]>({ + queryKey: ['albums'], + queryFn: () => albumService.listAlbums(), + }); + return ( + <div className="space-y-6"> + <h1 className="flex items-center gap-3 text-3xl font-bold text-primary"> + <Disc3 size={28} className="text-accent" /> Albums + </h1> + {isLoading ? <p className="text-sm text-muted">Loading…</p> + : isError ? <p className="text-sm text-muted">Couldn't load albums.</p> + : !data?.length ? <p className="text-sm text-muted">No albums yet.</p> + : ( + <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"> + {data.map((album) => ( + <Link key={album.id} to="/albums/$albumId" params={{ albumId: album.id }} + className="group flex flex-col gap-2 rounded-xl border border-line bg-surface p-3 hover:bg-surface-h transition-colors"> + <div className="aspect-square rounded-lg overflow-hidden"> + <Artwork seed={album.title} className="w-full h-full" rounded="lg" /> + </div> + <div> + <div className="truncate text-sm font-medium text-primary">{album.title}</div> + {album.year && <div className="text-xs text-muted">{album.year}</div>} + </div> + </Link> + ))} + </div> + )} + </div> + ); +} +``` + +5. **`AlbumDetail.tsx`** — tokens + `Artwork` + `TrackRow`: +```tsx +import { useQuery } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { Play, ArrowLeft } from 'lucide-react'; +import { albumDetailRoute } from '../router'; +import { albumService } from '../services/albumService'; +import { usePlaybackStore } from '../store/usePlaybackStore'; +import { Artwork } from '../components/Artwork'; +import { TrackRow } from '../components/TrackRow'; +import type { AlbumWithTracks } from '../types'; + +export default function AlbumDetail() { + const { albumId } = albumDetailRoute.useParams(); + const { setQueue, playTrack } = usePlaybackStore(); + const { data, isLoading, isError } = useQuery<AlbumWithTracks>({ + queryKey: ['album', albumId], + queryFn: () => albumService.getAlbum(albumId), + }); + if (isLoading) return <p className="text-sm text-muted">Loading…</p>; + if (isError || !data) return <p className="text-sm text-muted">Couldn't load album.</p>; + const tracks = data.tracks ?? []; + return ( + <div className="space-y-8 max-w-3xl"> + <Link to="/albums" className="inline-flex items-center gap-1 text-sm text-muted hover:text-primary"> + <ArrowLeft size={16} /> Albums + </Link> + <div className="flex items-end gap-5"> + <div className="w-40 h-40 flex-none rounded-xl overflow-hidden"> + <Artwork seed={data.title} className="w-full h-full" rounded="xl" /> + </div> + <div className="space-y-2"> + <h1 className="text-4xl font-bold text-primary">{data.title}</h1> + <p className="text-sm text-muted">{data.year ? `${data.year} · ` : ''}{tracks.length} {tracks.length === 1 ? 'track' : 'tracks'}</p> + <button onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }} + disabled={!tracks.length} + className="inline-flex items-center gap-2 rounded-full bg-accent hover:bg-accent-h px-5 py-2 text-sm font-semibold text-on-accent disabled:opacity-50 transition-colors"> + <Play size={16} fill="currentColor" /> Play album + </button> + </div> + </div> + <section className="space-y-1"> + {tracks.length === 0 ? <p className="text-sm text-muted">No tracks.</p> + : tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} trackNumber={i + 1} />)} + </section> + </div> + ); +} +``` + +6. **`Genres.tsx`** — tokens; genre cards use gradient derived from genre name: +```tsx +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Tag, Play, ArrowLeft } from 'lucide-react'; +import { genreService } from '../services/genreService'; +import { usePlaybackStore } from '../store/usePlaybackStore'; +import { TrackRow } from '../components/TrackRow'; +import type { Genre, Track } from '../types'; + +function hueFrom(s: string) { + let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; + return Math.abs(h) % 360; +} + +export default function Genres() { + const [selected, setSelected] = useState<Genre | null>(null); + const { setQueue, playTrack } = usePlaybackStore(); + + const genresQ = useQuery<Genre[]>({ queryKey: ['genres'], queryFn: () => genreService.listGenres() }); + const tracksQ = useQuery<Track[]>({ + queryKey: ['genre-tracks', selected?.id], + queryFn: () => genreService.getGenreTracks(selected!.id), + enabled: !!selected, + }); + + if (selected) { + const tracks = tracksQ.data ?? []; + return ( + <div className="space-y-6 max-w-3xl"> + <button onClick={() => setSelected(null)} className="inline-flex items-center gap-1 text-sm text-muted hover:text-primary"> + <ArrowLeft size={16} /> Genres + </button> + <div className="flex items-center justify-between"> + <h1 className="flex items-center gap-3 text-3xl font-bold text-primary"><Tag size={28} className="text-accent" />{selected.name}</h1> + <button onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }} disabled={!tracks.length} + className="inline-flex items-center gap-2 rounded-full bg-accent hover:bg-accent-h px-4 py-2 text-sm font-semibold text-on-accent disabled:opacity-50"> + <Play size={16} fill="currentColor" /> Play all + </button> + </div> + {tracksQ.isLoading ? <p className="text-sm text-muted">Loading…</p> + : tracks.length === 0 ? <p className="text-sm text-muted">No tracks.</p> + : <div className="space-y-1">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>} + </div> + ); + } + + return ( + <div className="space-y-6"> + <h1 className="flex items-center gap-3 text-3xl font-bold text-primary"><Tag size={28} className="text-accent" />Genres</h1> + {genresQ.isLoading ? <p className="text-sm text-muted">Loading…</p> + : genresQ.isError ? <p className="text-sm text-muted">Couldn't load genres.</p> + : !genresQ.data?.length ? <p className="text-sm text-muted">No genres yet.</p> + : ( + <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4"> + {genresQ.data.map((genre) => { + const hue = hueFrom(genre.name); + return ( + <button key={genre.id} onClick={() => setSelected(genre)} + className="group flex flex-col items-start gap-2 rounded-xl border border-line p-4 text-left transition-colors hover:border-accent/40" + style={{ background: `linear-gradient(135deg, hsl(${hue},40%,15%), hsl(${(hue+60)%360},30%,10%))` }}> + <Tag size={20} className="text-muted group-hover:text-accent" /> + <div className="w-full truncate font-medium text-primary">{genre.name}</div> + <div className="text-xs text-muted">{genre.track_count ?? 0} tracks</div> + </button> + ); + })} + </div> + )} + </div> + ); +} +``` + +**Acceptance criteria:** All 6 pages compile; no `zinc-*` or `gray-*` hard-coded color references remain; `LibraryTrackRow` no longer imported. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 12 — Restyle Discover, Vibe, Search + +**Goal:** Apply semantic token classes; use `TrackRow` in Search; keep all logic intact. + +**Files:** `Discover.tsx`, `Vibe.tsx`, `Search.tsx` (all modify) + +**Steps:** + +1. **`Discover.tsx`** — swap `zinc-*` for tokens; use `TrackRow` for the track list: + - Replace `border-zinc-800 bg-zinc-900/50 hover:border-zinc-700 hover:bg-zinc-800/70` → `border-line bg-surface hover:bg-surface-h` + - Replace `text-zinc-400` → `text-muted`, `text-zinc-200` → `text-primary`, `text-white` → `text-primary` + - The genre card active state `border-blue-500/70 bg-blue-500/10` → `border-accent/60 bg-accent/10` + - Replace the inline `<button>` track list rows with `<TrackRow>` (pass `showActions={false}`) + - The "Start a vibe" button: `border-blue-500/60 bg-blue-500/10 text-blue-300 hover:bg-blue-500/20` → `border-accent/60 bg-accent/10 text-accent hover:bg-accent/20` + - Keep all logic, hooks, imports unchanged except adding `TrackRow` import and removing the inline button track row + +2. **`Vibe.tsx`** — same token swap; keep all logic: + - All `bg-zinc-900/50 border-zinc-800` → `bg-surface border-line` + - `hover:bg-zinc-800/70` → `hover:bg-surface-h` + - `text-zinc-400/500` → `text-muted` + - `text-zinc-200/300` → `text-primary` + - The seed-picker list buttons swap to `<TrackRow showActions={false}>` for the seed picker list + - The session buttons ("Keep", "Dislike & skip", "End Vibe"): token border/text classes + - The `bg-blue-500/10 border-blue-500/60 text-blue-300/400` accents → `bg-accent/10 border-accent/60 text-accent` + - `VibeTimeline` is used as-is (it will be restyled in its own file in a follow-up, but for now leave it) + +3. **`Search.tsx`** — swap `LibraryTrackRow` for `TrackRow`; token classes on the input: +```tsx +import { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Search as SearchIcon } from 'lucide-react'; +import { searchService } from '../services/searchService'; +import { TrackRow } from '../components/TrackRow'; +import type { SearchResponse, Track } from '../types'; + +export default function Search() { + const [input, setInput] = useState(''); + const [query, setQuery] = useState(''); + useEffect(() => { const id = setTimeout(() => setQuery(input.trim()), 300); return () => clearTimeout(id); }, [input]); + const { data, isLoading, isError, isFetching } = useQuery<SearchResponse>({ + queryKey: ['search', query], + queryFn: () => searchService.search(query), + enabled: query.length > 0, + }); + const tracks: Track[] = (data?.hits ?? []).map((h) => h.document).filter((t): t is Track => Boolean(t)); + return ( + <div className="space-y-6 max-w-3xl"> + <h1 className="text-3xl font-bold text-primary">Search</h1> + <div className="relative max-w-xl"> + <SearchIcon size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" /> + <input type="search" value={input} onChange={(e) => setInput(e.target.value)} + placeholder="Search tracks, artists…" autoFocus + className="w-full rounded-lg border border-line bg-surface/70 py-2.5 pl-10 pr-4 text-sm text-primary placeholder:text-muted outline-none focus:border-accent transition-colors" /> + </div> + {query.length === 0 ? <p className="text-sm text-muted">Type to search.</p> + : isLoading || isFetching ? <p className="text-sm text-muted">Searching…</p> + : isError ? <p className="text-sm text-muted">Search failed.</p> + : tracks.length === 0 ? <p className="text-sm text-muted">No results for "{query}".</p> + : <div className="space-y-1">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>} + </div> + ); +} +``` + +**Acceptance criteria:** No `LibraryTrackRow` import in any of the three files; no hard-coded `zinc-*`/`gray-*` colors. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 13 — Real Quarantine page + quarantineService + +**Goal:** Add `DislikeEntry` to `types.ts`; create `quarantineService.ts`; rewrite `Quarantine.tsx` with a real list, countdowns, Restore, and hard-Delete actions. + +**Files:** `frontend/src/types.ts` (modify), `frontend/src/services/quarantineService.ts` (create), `frontend/src/pages/Quarantine.tsx` (rewrite) + +**Steps:** + +1. Add to `frontend/src/types.ts`: +```ts +export interface DislikeEntry { + track_id: string; + disliked_at: string; + warned_at: string | null; + deleted_at: string | null; + grace_hours: number; + state: 'HIDDEN' | 'WARNED' | 'DELETED' | string; + track_title: string; + track_artist: string; + track_path: string; +} +``` + +2. Create `frontend/src/services/quarantineService.ts`: +```ts +import api from './api'; +import type { DislikeEntry } from '../types'; + +export const quarantineService = { + async list(): Promise<DislikeEntry[]> { + const res = await api.get<DislikeEntry[]>('/dislikes'); + return res.data; + }, + + async restore(trackId: string): Promise<void> { + await api.post(`/dislikes/${trackId}/restore`); + }, + + async hardDelete(trackId: string): Promise<void> { + await api.delete(`/dislikes/${trackId}`); + }, +}; +``` + +3. Rewrite `frontend/src/pages/Quarantine.tsx`: +```tsx +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ShieldAlert, RotateCcw, Trash2, Clock } from 'lucide-react'; +import { quarantineService } from '../services/quarantineService'; +import type { DislikeEntry } from '../types'; + +function countdown(entry: DislikeEntry): string { + const base = entry.warned_at + ? new Date(entry.warned_at).getTime() + 24 * 3600 * 1000 + : new Date(entry.disliked_at).getTime() + entry.grace_hours * 3600 * 1000; + const ms = base - Date.now(); + if (ms <= 0) return 'Deleting soon'; + const h = Math.floor(ms / 3600000); + const m = Math.floor((ms % 3600000) / 60000); + return h > 0 ? `${h}h ${m}m remaining` : `${m}m remaining`; +} + +function stateLabel(state: string) { + if (state === 'WARNED') return <span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-300 font-medium">Warning sent</span>; + return <span className="text-xs px-2 py-0.5 rounded-full bg-surface-h text-muted font-medium">Grace period</span>; +} + +export default function Quarantine() { + const qc = useQueryClient(); + const { data, isLoading, isError } = useQuery<DislikeEntry[]>({ + queryKey: ['dislikes'], + queryFn: () => quarantineService.list(), + refetchInterval: 60_000, + }); + + const restore = useMutation({ + mutationFn: (trackId: string) => quarantineService.restore(trackId), + onSuccess: () => qc.invalidateQueries({ queryKey: ['dislikes'] }), + }); + + const hardDelete = useMutation({ + mutationFn: (trackId: string) => quarantineService.hardDelete(trackId), + onSuccess: () => qc.invalidateQueries({ queryKey: ['dislikes'] }), + }); + + const entries = data ?? []; + + return ( + <div className="space-y-6 max-w-3xl"> + <div> + <h1 className="flex items-center gap-3 text-3xl font-bold text-primary"> + <ShieldAlert size={28} className="text-accent" /> Quarantine + </h1> + <p className="text-muted mt-1">Disliked tracks pending deletion. Restore before the timer expires.</p> + </div> + + {isLoading ? <p className="text-sm text-muted">Loading…</p> + : isError ? <p className="text-sm text-muted">Couldn't load quarantine list.</p> + : entries.length === 0 ? ( + <div className="flex flex-col items-center gap-3 rounded-xl border border-line border-dashed py-16 text-center"> + <ShieldAlert size={32} className="text-muted/40" /> + <p className="text-muted">No tracks in quarantine.</p> + <p className="text-sm text-muted/60">Disliked tracks will appear here during the grace period.</p> + </div> + ) : ( + <ul className="space-y-2"> + {entries.map((entry) => ( + <li key={entry.track_id} className="flex items-center gap-3 rounded-lg border border-line bg-surface p-3"> + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-2 flex-wrap"> + <span className="text-sm font-semibold text-primary truncate">{entry.track_title}</span> + {stateLabel(entry.state)} + </div> + <div className="text-xs text-muted">{entry.track_artist}</div> + <div className="flex items-center gap-1 mt-1 text-xs text-muted"> + <Clock size={12} /> {countdown(entry)} + </div> + </div> + <div className="flex items-center gap-2 shrink-0"> + <button + onClick={() => restore.mutate(entry.track_id)} + disabled={restore.isPending} + title="Restore to library" + className="flex items-center gap-1.5 rounded-lg border border-line px-3 py-1.5 text-sm text-primary hover:bg-surface-h disabled:opacity-50 transition-colors" + > + <RotateCcw size={14} /> Restore + </button> + <button + onClick={() => { if (confirm(`Permanently delete "${entry.track_title}"?`)) hardDelete.mutate(entry.track_id); }} + disabled={hardDelete.isPending} + title="Delete now" + className="flex items-center gap-1.5 rounded-lg border border-red-500/40 px-3 py-1.5 text-sm text-red-400 hover:bg-red-500/10 disabled:opacity-50 transition-colors" + > + <Trash2 size={14} /> Delete + </button> + </div> + </li> + ))} + </ul> + )} + </div> + ); +} +``` + +**Acceptance criteria:** Page lists disliked tracks from the real backend; Restore clears the row and returns track to library; Delete prompts confirmation; countdown shows time remaining. + +**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0` + +--- + +## Task 14 — Settings restyle + final typecheck + +**Goal:** Restyle Settings with semantic tokens; remove hard-coded `gray-800`/`gray-700` classes; keep theme + volume logic intact. Then run a final typecheck. + +**Files:** `frontend/src/pages/Settings.tsx` (modify) + +**Steps:** + +1. Rewrite `frontend/src/pages/Settings.tsx` (logic unchanged, colors replaced): +```tsx +import { useEffect, useState } from 'react'; +import { Palette, Volume2, Info, Check } from 'lucide-react'; +import { usePlaybackStore } from '../store/usePlaybackStore'; +import api from '../services/api'; +import { THEMES, DEFAULT_THEME_ID, STORAGE_KEYS, applyTheme, readStoredThemeId, readStoredVolume, type ThemePreset } from '../lib/theme'; + +export default function Settings() { + const volume = usePlaybackStore((s) => s.volume); + const setVolume = usePlaybackStore((s) => s.setVolume); + const [themeId, setThemeId] = useState<string>(DEFAULT_THEME_ID); + + useEffect(() => { + const id = readStoredThemeId(); + setThemeId(id); + const t = THEMES.find((x) => x.id === id); + if (t) applyTheme(t); + setVolume(readStoredVolume(volume)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const selectTheme = (theme: ThemePreset) => { + setThemeId(theme.id); + applyTheme(theme); + try { localStorage.setItem(STORAGE_KEYS.theme, theme.id); } catch { /**/ } + }; + + const handleVolume = (v: number) => { + setVolume(v); + try { localStorage.setItem(STORAGE_KEYS.volume, String(v)); } catch { /**/ } + }; + + return ( + <div className="space-y-8 max-w-2xl"> + <div> + <h1 className="text-3xl font-bold text-primary">Settings</h1> + <p className="text-muted mt-1">Preferences are stored locally in this browser.</p> + </div> + + <section className="rounded-xl border border-line bg-surface p-5 space-y-4"> + <h2 className="text-lg font-semibold text-primary flex items-center gap-2"><Palette size={20} className="text-accent" />Theme</h2> + <div className="grid grid-cols-2 sm:grid-cols-3 gap-3"> + {THEMES.map((theme) => { + const active = theme.id === themeId; + return ( + <button key={theme.id} onClick={() => selectTheme(theme)} + className={`relative flex flex-col items-start gap-2 rounded-lg border p-3 text-left transition-colors ${active ? 'border-accent ring-2 ring-accent/30' : 'border-line hover:border-accent/40'}`}> + <span className="h-10 w-full rounded-md border border-black/20" style={{ backgroundColor: theme.swatch }} /> + <span className="text-sm font-medium text-primary">{theme.name}</span> + {active && <Check size={14} className="absolute right-2 top-2 text-accent" />} + </button> + ); + })} + </div> + </section> + + <section className="rounded-xl border border-line bg-surface p-5 space-y-4"> + <h2 className="text-lg font-semibold text-primary flex items-center gap-2"><Volume2 size={20} className="text-accent" />Default volume</h2> + <div className="flex items-center gap-4"> + <input type="range" min={0} max={1} step={0.01} value={volume} + onChange={(e) => handleVolume(Number(e.target.value))} + className="flex-1 accent-[var(--accent)]" aria-label="Volume" /> + <span className="w-12 text-right text-sm tabular-nums text-primary">{Math.round(volume * 100)}%</span> + </div> + </section> + + <section className="rounded-xl border border-line bg-surface p-5 space-y-3"> + <h2 className="text-lg font-semibold text-primary flex items-center gap-2"><Info size={20} className="text-accent" />About</h2> + <dl className="space-y-2 text-sm"> + <div className="flex justify-between"><dt className="text-muted">Application</dt><dd className="text-primary font-medium">muzick</dd></div> + <div className="flex justify-between"><dt className="text-muted">Version</dt><dd className="text-primary font-medium tabular-nums">0.1.0</dd></div> + <div className="flex justify-between gap-4"><dt className="text-muted">API base</dt><dd className="font-mono text-xs text-primary break-all">{api.defaults.baseURL ?? '/api'}</dd></div> + </dl> + </section> + </div> + ); +} +``` + +2. Also update `VibeTimeline.tsx` to use tokens (it's used by Vibe): +```tsx +// Replace zinc-* with token classes throughout VibeTimeline.tsx: +// bg-zinc-900/50 border-zinc-800 → bg-surface border-line +// hover:border-zinc-700 hover:bg-zinc-800/70 → hover:bg-surface-h +// text-zinc-200 → text-primary +// text-zinc-400/500 → text-muted +// text-zinc-600 → text-muted/60 +// bg-blue-500/20 text-blue-300 → bg-accent/20 text-accent +// bg-blue-500 text-white → bg-accent text-on-accent +// bg-zinc-800 text-zinc-500 → bg-elevated text-muted +// The 'Now playing' span: bg-blue-500 → bg-accent +``` + +3. Delete `frontend/src/components/Layout.tsx`, `frontend/src/components/NowPlayingBar.tsx`, `frontend/src/pages/LibraryTrackRow.tsx`. + +4. Run final typecheck: +```bash +cd frontend && npm run typecheck +``` + +**Acceptance criteria:** Zero TypeScript errors. No remaining imports of `Layout`, `NowPlayingBar`, or `LibraryTrackRow`. + +**Verify:** +```bash +cd /mnt/server/home/kami/apps/muzick/frontend && npm run typecheck 2>&1 | tail -5 +# Expected: no output or "Found 0 errors." +grep -r "LibraryTrackRow\|NowPlayingBar\|from.*Layout" src/ | grep -v "\.md" +# Expected: no output +``` + +--- + +## Execution order + +Tasks are ordered by dependency: + +``` +1 (tokens) → 2 (Artwork) → 3 (TrackRow) → 4 (PlaybackBar) → 5 (NowPlayingPanel) + → 6 (NavRail) → 7 (TopBar) → 8 (AppShell+router) + → 9 (MediaCard+ShelfRow) → 10 (Home) + → 11 (library pages) → 12 (Discover/Vibe/Search) + → 13 (Quarantine) → 14 (Settings + typecheck) +``` + +Tasks 2–7 have no inter-dependencies and can be written in parallel; they all depend only on task 1. +Tasks 11–13 depend on tasks 1–3. + +--- + +## Execute now with `/implement`? diff --git a/docs/ui-rework.md b/docs/ui-rework.md new file mode 100644 index 0000000..60b8793 --- /dev/null +++ b/docs/ui-rework.md @@ -0,0 +1,139 @@ +# UI Rework Plan + +> Status: **planned, not started.** This document captures the target direction for a +> richer player UI (reference: the "LocalTunes" three-pane mockup) and what it implies for +> both the frontend and the backend. It is a plan to execute later, not a description of the +> current app. + +## 1. Vision + +Move from the current functional-but-plain single-content-column layout to a polished, +artwork-forward **three-pane music player** in the spirit of modern desktop players +(Spotify / Apple Music / the LocalTunes reference): + +- **Left:** persistent navigation rail (library sections + a personal/"your music" group). +- **Center:** scrollable content (Home, Library, Vibe, etc.) — artwork-rich cards, horizontal + carousels, hover-to-play. +- **Right:** persistent **Now Playing** panel — large artwork, track info, transport, and an + **Up Next / queue** list. +- **Bottom:** full-width global **playback bar** (shuffle / prev / play / next / repeat, + scrubber, volume, queue toggle) that's always visible regardless of route. +- **Top:** global search field + (future) user/account menu. + +Accent-driven, rounded, soft-gradient cards; dark by default but fully themeable via tokens. + +## 2. Layout structure + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ Top bar: [logo] [ global search ⌘K ] [bell] [avatar ▾] │ +├───────────────┬────────────────────────────────────────────┬───────────────┤ +│ Nav rail │ Content (router Outlet) │ Now Playing │ +│ - Home │ Good evening 👋 │ [ artwork ] │ +│ - Songs │ Quick Access cards │ Title/Artist │ +│ - Albums │ Recently Played (carousel, View all) │ scrubber │ +│ - Artists │ Made for you (mixes carousel) │ transport │ +│ - Genres │ ... │ Up Next list │ +│ - Playlists │ │ │ +│ - Folder │ │ (collapsible)│ +│ ────────── │ │ │ +│ Now Playing │ │ │ +│ Recently … │ │ │ +│ Most Played │ │ │ +│ Favorites │ │ │ +│ ────────── │ │ │ +│ Settings │ │ │ +│ Theme │ │ │ +│ About │ │ │ +├───────────────┴────────────────────────────────────────────┴───────────────┤ +│ Bottom bar: [art] Title/Artist ♥ ⇄ ◀ ▶▶ ⏯ ▶▶ ↻ 🔊────── queue ▤ │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +The right Now-Playing panel and the bottom bar are partly redundant by design (desktop +players do this): the bottom bar is the always-on minimal transport; the right panel is the +expanded view with queue and large art, and is collapsible. + +## 3. Design tokens / theming + +This rework is the right moment to finish theming. Today only the shell consumes tokens +(`--bg`, `--surface`, `--text`, `--accent` from `src/lib/theme.ts`). Target: + +- **Expand the token set:** `--bg`, `--bg-elevated`, `--surface`, `--surface-hover`, + `--border`, `--text`, `--text-muted`, `--accent`, `--accent-hover`, `--on-accent`, + plus gradient stops for cards (`--card-grad-a/b`). +- **Drive Tailwind from the tokens:** extend `tailwind.config.js` `theme.colors` to reference + the CSS variables (e.g. `bg: 'var(--bg)'`, `surface: 'var(--surface)'`, `accent: + 'var(--accent)'`) so components use semantic classes (`bg-surface`, `text-muted`, + `bg-accent`) instead of hard-coded `bg-zinc-900` etc. This makes every component themeable + without per-component edits. +- Keep the existing presets (Dark / Midnight / Forest / Plum), add a light option, and keep + `initTheme()` applying the persisted choice before first paint. +- The reference's purple accent → add a "Default (Purple)" preset. + +## 4. Component inventory (new / reworked) + +| Component | Purpose | +| :--- | :--- | +| `AppShell` | 3-pane grid (rail / content / now-playing) + top bar + bottom bar. Replaces `Layout`. | +| `NavRail` | Sections + personal group + settings group; active state via `--accent`. | +| `TopBar` | Global search (debounced, ⌘K focus), account menu (stub until auth). | +| `NowPlayingPanel` | Right rail: large art, info, scrubber, transport, Up Next queue (reorder/remove). Collapsible. | +| `PlaybackBar` | Bottom global transport (always visible). Reworks `NowPlayingBar`. | +| `MediaCard` | Square artwork card with hover play overlay (used by carousels + grids). | +| `Carousel` / `ShelfRow` | Horizontal scroll row with title + "View all". | +| `QuickAccessCard` | Wide gradient card (Favorites / Recently Added / Most Played / Folder). | +| `TrackRow` | Reusable list row (replaces the per-page `LibraryTrackRow`) with art, actions, now-playing highlight. | +| `Artwork` | Resolves album/track artwork URL with a graceful gradient placeholder fallback. | + +State: keep Zustand `usePlaybackStore` (current/queue/isPlaying/position/volume) and +`useVibeStore`; add a small `useUiStore` for panel collapse + theme if useful. The Up Next +list is just the playback `queue`. + +## 5. Backend work this UI implies (gaps) + +The mockup assumes data we don't serve yet. Each is a discrete backend task: + +1. **Artwork serving** — `albums.artwork_id` / Cover-Art URLs are stored but never served. + Need `GET /api/albums/:id/artwork` (and/or per-track) that streams/redirects to the cached + cover, plus a placeholder when absent. Without this every card is a gradient placeholder. +2. **Playlists** — the rail shows "Playlists"; there are no playlist tables/endpoints. Needs + `playlists` + `playlist_track` schema and CRUD + reorder endpoints. (Net-new feature.) +3. **"Most Played"** — derivable now via `GET /api/tracks?sort_by=play_count&order=DESC`. + Wire a dedicated view/shelf. +4. **"Recently Added"** — needs reliable `mtime`/`created_at` sorting (currently sorted + client-side). Consider a `created_at` column + a sorted endpoint. +5. **"Made for you" mixes** — map to the Vibe engine: per-genre/seed mixes via + `/api/vibe/from-genre` and saved seeds. No new engine work, just presentation + maybe a + "mixes" endpoint that returns a handful of seed suggestions. +6. **Folder browse** — the rail shows "Folder"; there's no filesystem-browse endpoint. Needs + a sandboxed `GET /api/library/browse?path=` under `MUSIC_DIR` (reuse the stream route's + traversal guard). Optional / later. +7. **Typesense search** — the redesigned top-bar search wants fast fuzzy results; finish the + Typesense indexing pipeline (collection + reindex job + index-on-enrich) so search graduates + from the Postgres ILIKE fallback. (Already tracked in `progress.md`.) + +## 6. Suggested phasing + +1. **Tokenise theming** — extend tokens + wire Tailwind to CSS vars; migrate existing + components to semantic colour classes. (Unblocks real theming; low risk, high leverage.) +2. **AppShell + PlaybackBar + NowPlayingPanel** — the structural 3-pane shell with the + always-on transport and queue, reusing the current playback store/audio engine. +3. **MediaCard / Carousel / Artwork** + **artwork backend endpoint** — make the content + artwork-forward; redesign Home around Quick Access + shelves. +4. **Library/Discover/Vibe pages** restyled onto the new components. +5. **New features as desired:** Playlists, Folder browse, Most Played/Recently Added shelves, + Typesense search. + +## 7. Non-goals (for the first rework pass) + +- Auth / multi-user (still single-user). +- Mobile/responsive layout (target desktop first; the 3-pane collapses later). +- Real-time collaborative features. + +## 8. Open questions + +- Keep both the right Now-Playing panel **and** the bottom bar, or collapse to one? (Plan + assumes both, panel collapsible.) +- Artwork storage: serve via a backend proxy/cache, or store files locally and serve static? +- Playlists: is this in scope for the rework, or a separate feature track? diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..3f29ae6 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.git +.env diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..54abf9e --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-slim AS build +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:stable-alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9d49019 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Muzick + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..f54f59e --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,18 @@ +server { + listen 80; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + location /api { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..867dec4 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3208 @@ +{ + "name": "muzick", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "muzick", + "dependencies": { + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-router": "^1.170.15", + "axios": "^1.17.0", + "date-fns": "^4.4.0", + "lucide-react": "^1.17.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "zod": "^4.4.3", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@types/react": "^18.3.31", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.5.0", + "postcss": "^8.5.15", + "tailwindcss": "^3.4.19", + "typescript": "^5.9.3", + "vite": "^5.2.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/history": { + "version": "1.162.0", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.0.tgz", + "integrity": "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.170.15", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.15.tgz", + "integrity": "sha512-GawYz7HEjj8rTUUDoT/SemDEVm63pZUO+2mOcXHY9Jl3EwMS5gFBnPu/2UvcrwRm1jN1k79fokc0d4aFmrLatg==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.0", + "@tanstack/react-store": "^0.9.3", + "@tanstack/router-core": "1.171.13", + "isbot": "^5.1.22" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.171.13", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.13.tgz", + "integrity": "sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.0", + "cookie-es": "^3.0.0", + "seroval": "^1.5.4", + "seroval-plugins": "^1.5.4" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.368", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", + "integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isbot": { + "version": "5.1.41", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.41.tgz", + "integrity": "sha512-9WFV/Vhh0FEj6CQ7MoHweEL9/vLKPjeoD2I2htbAjX7kbW7VJs3OCpWOVyd+JraNTWVU6/DRx2MZy2KaUNXHcg==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", + "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/seroval": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.4.tgz", + "integrity": "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.4.tgz", + "integrity": "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..13363a4 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "muzick", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-router": "^1.170.15", + "axios": "^1.17.0", + "date-fns": "^4.4.0", + "geist": "^1.7.2", + "lucide-react": "^1.17.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "zod": "^4.4.3", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@types/react": "^18.3.31", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.5.0", + "postcss": "^8.5.15", + "tailwindcss": "^3.4.19", + "typescript": "^5.9.3", + "vite": "^5.2.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/fonts/Geist-Bold.woff2 b/frontend/public/fonts/Geist-Bold.woff2 new file mode 100644 index 0000000..870db68 Binary files /dev/null and b/frontend/public/fonts/Geist-Bold.woff2 differ diff --git a/frontend/public/fonts/Geist-Light.woff2 b/frontend/public/fonts/Geist-Light.woff2 new file mode 100644 index 0000000..c049fcd Binary files /dev/null and b/frontend/public/fonts/Geist-Light.woff2 differ diff --git a/frontend/public/fonts/Geist-Medium.woff2 b/frontend/public/fonts/Geist-Medium.woff2 new file mode 100644 index 0000000..3a62ba4 Binary files /dev/null and b/frontend/public/fonts/Geist-Medium.woff2 differ diff --git a/frontend/public/fonts/Geist-Regular.woff2 b/frontend/public/fonts/Geist-Regular.woff2 new file mode 100644 index 0000000..d98d41c Binary files /dev/null and b/frontend/public/fonts/Geist-Regular.woff2 differ diff --git a/frontend/public/fonts/Geist-SemiBold.woff2 b/frontend/public/fonts/Geist-SemiBold.woff2 new file mode 100644 index 0000000..ea20a79 Binary files /dev/null and b/frontend/public/fonts/Geist-SemiBold.woff2 differ diff --git a/frontend/public/fonts/GeistMono-Medium.woff2 b/frontend/public/fonts/GeistMono-Medium.woff2 new file mode 100644 index 0000000..db3b40a Binary files /dev/null and b/frontend/public/fonts/GeistMono-Medium.woff2 differ diff --git a/frontend/public/fonts/GeistMono-Regular.woff2 b/frontend/public/fonts/GeistMono-Regular.woff2 new file mode 100644 index 0000000..f76e553 Binary files /dev/null and b/frontend/public/fonts/GeistMono-Regular.woff2 differ diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..a879eea --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -0,0 +1,74 @@ +import { useState, useCallback } from 'react'; +import { Outlet } from '@tanstack/react-router'; +import { AudioEngine } from './AudioEngine'; +import { NavRail } from './NavRail'; +import { TopBar } from './TopBar'; +import { PlaybackBar } from './PlaybackBar'; +import { NowPlayingPanel } from './NowPlayingPanel'; +import { LyricsOverlay } from './LyricsOverlay'; +import { Toaster } from './Toaster'; +import { CommandPalette } from './CommandPalette'; +import { KeyboardListener, useKeyboard } from '../hooks/useKeyboard'; +import { Inspector, type InspectorMode } from './Inspector'; + +export default function AppShell() { + const [queueOpen, setQueueOpen] = useState(false); + const [lyricsOpen, setLyricsOpen] = useState(false); + const [paletteOpen, setPaletteOpen] = useState(false); + const [inspector, setInspector] = useState<{ mode: InspectorMode; id: string } | null>(null); + + const togglePalette = useCallback(() => setPaletteOpen((p) => !p), []); + const closeInspector = useCallback(() => setInspector(null), []); + + // Ctrl+K — command palette (uses `code` so it works on any keyboard layout) + useKeyboard({ + code: 'KeyK', + ctrl: true, + handler: () => setPaletteOpen((p) => !p), + }); + + // Alt+← / Alt+→ — back / forward (uses `code` for layout independence) + useKeyboard({ + code: 'ArrowLeft', + alt: true, + handler: () => window.history.back(), + }); + useKeyboard({ + code: 'ArrowRight', + alt: true, + handler: () => window.history.forward(), + }); + + // Esc — closes inspector, palette, etc. + useKeyboard({ + code: 'Escape', + handler: () => { + if (inspector) closeInspector(); + }, + }); + + return ( +
+ + +
+ +
+ +
+ {inspector && } + {queueOpen && setQueueOpen(false)} />} + {lyricsOpen && setLyricsOpen(false)} />} +
+ setQueueOpen((o) => !o)} + onToggleLyrics={() => setLyricsOpen((o) => !o)} + /> + + + setPaletteOpen(false)} /> +
+ ); +} diff --git a/frontend/src/components/ArtistLinks.tsx b/frontend/src/components/ArtistLinks.tsx new file mode 100644 index 0000000..b6bd9e3 --- /dev/null +++ b/frontend/src/components/ArtistLinks.tsx @@ -0,0 +1,67 @@ +import { Link } from '@tanstack/react-router'; +import type { TrackArtist } from '../types'; + +interface ArtistLinksProps { + /** Ordered artists (first = main, rest = featured). */ + artists?: TrackArtist[] | null; + /** Shown when there are no structured artists (plain text, not a link). */ + fallback?: string; + className?: string; + /** Stop row/card click handlers from firing when an artist link is clicked. */ + stopPropagation?: boolean; +} + +/** + * Deduplicates artists by ID, preferring `main` over `featured` when the + * same artist has both roles (can happen because track_artists has a composite + * PK of track_id + artist_id + role). + */ +function deduplicateArtists(artists: TrackArtist[]): TrackArtist[] { + const map = new Map(); + for (const a of artists) { + const existing = map.get(a.id); + if (!existing || (existing.role === 'featured' && a.role === 'main')) { + map.set(a.id, a); + } + } + // Preserve original order, skipping duplicates. + const seen = new Set(); + return artists.filter((a) => { + if (seen.has(a.id)) return false; + seen.add(a.id); + return true; + }); +} + +/** + * Renders a track/album's artists as clickable links — main artist(s) then + * "feat." guests. Single source of truth used by TrackRow, the playback bar, + * the now-playing panel and album pages so artist navigation looks and behaves + * the same everywhere. + * + * Artists are deduplicated by id — if the same artist appears as both main + * and featured, only the main entry is shown. + */ +export function ArtistLinks({ artists, fallback, className = '', stopPropagation }: ArtistLinksProps) { + if (!artists || artists.length === 0) { + return {fallback || 'Unknown artist'}; + } + const unique = deduplicateArtists(artists); + return ( + + {unique.map((a, i) => ( + + {i > 0 && {a.role === 'featured' && unique[i - 1].role !== 'featured' ? ' feat. ' : ', '}} + e.stopPropagation() : undefined} + className={`hover:text-text hover:underline ${a.role === 'featured' ? 'opacity-75' : ''}`} + > + {a.name} + + + ))} + + ); +} diff --git a/frontend/src/components/Artwork.tsx b/frontend/src/components/Artwork.tsx new file mode 100644 index 0000000..721be0e --- /dev/null +++ b/frontend/src/components/Artwork.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react'; +import { Music } from 'lucide-react'; +import { hueFromString } from '../lib/color'; + +interface ArtworkProps { + seed: string; + src?: string | null; + className?: string; + rounded?: 'sm' | 'md' | 'lg' | 'xl' | 'full'; + /** Skip native lazy-loading — set true for above-the-fold artwork (e.g. PlaybackBar). */ + eager?: boolean; +} + +const API_BASE = import.meta.env.VITE_API_URL || '/api'; + +/** + * Rewrite external image URLs through the backend proxy so the browser gets + * cache headers (1 year, immutable) and avoids per-domain connection limits + * to Discogs / Cover Art Archive etc. + */ +function proxySrc(src: string): string { + if (src.startsWith('http://') || src.startsWith('https://')) { + return `${API_BASE}/images/proxy?url=${encodeURIComponent(src)}`; + } + return src; +} + +export function Artwork({ seed, src, className = '', rounded = 'md', eager = false }: ArtworkProps) { + const hue = hueFromString(seed); + // Symmetric top sheen over a diagonal base — the highlight is centered + // horizontally so it reads as even behind the centered note glyph. + const gradient = + `radial-gradient(110% 90% at 50% 0%, hsl(${hue},55%,30%) 0%, transparent 60%), ` + + `linear-gradient(160deg, hsl(${hue},48%,23%), hsl(${(hue + 55) % 360},40%,11%))`; + const r = { sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg', xl: 'rounded-xl', full: 'rounded-full' }[rounded]; + + // If an image source is supplied we render it lazily over the gradient + // fallback, so a slow or broken cover never produces a blank rectangle: + // the gradient (with the music glyph) is painted underneath and only + // swapped out once the fires its onLoad. A 404 falls back too. + const [loaded, setLoaded] = useState(false); + const [errored, setErrored] = useState(false); + + if (src && !errored) { + const proxied = proxySrc(src); + return ( +
+ {!loaded && } + {seed} setLoaded(true)} + onError={() => setErrored(true)} + className={`h-full w-full object-cover transition-opacity duration-300 ${loaded ? 'opacity-100' : 'opacity-0'}`} + /> +
+ ); + } + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/AudioEngine.tsx b/frontend/src/components/AudioEngine.tsx new file mode 100644 index 0000000..43c1c6b --- /dev/null +++ b/frontend/src/components/AudioEngine.tsx @@ -0,0 +1,291 @@ +import { useEffect, useRef } from 'react'; +import { usePlaybackStore } from '../store/usePlaybackStore'; +import { trackService } from '../services/trackService'; +import { vibeService } from '../services/vibeService'; +import type { Track } from '../types'; + +// Threshold (seconds) above which a store position change is treated as a user +// scrub and applied to the audio element. Keeps the timeupdate -> setPosition -> +// effect loop from fighting itself. +const SEEK_THRESHOLD = 1; + +// If a track reaches this fraction of its duration, treat it as "effectively +// completed" even if the user clicks Next before the very end. +const COMPLETION_THRESHOLD = 0.95; + +// Relative seek increment (seconds) for MediaSession seekforward/seekbackward. +const SEEK_INCREMENT = 10; + +/** Build the artwork URLs for MediaSession metadata (OS media controls). */ +function buildArtwork(track: Track): MediaImage[] { + const sizes = [96, 128, 192, 256, 384, 512]; + const artwork = track.artwork_id; + if (!artwork) return []; + // artwork_id is either a full URL (external) or a relative path served by us. + const url = artwork.startsWith('http') + ? artwork + : `${window.location.origin}${artwork}`; + return sizes.map((s) => ({ src: url, sizes: `${s}x${s}`, type: 'image/jpeg' })); +} + +// Headless audio engine: one shared