initial state: muzick music player + recommendation engine
This commit is contained in:
@@ -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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
+29
@@ -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
|
||||
@@ -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/<hash>.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.
|
||||
@@ -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:<artist>:<title>"
|
||||
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
|
||||
@@ -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>`.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
@@ -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"]
|
||||
Generated
+3206
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Initialize the database schema
|
||||
psql "$DATABASE_URL" -f src/db/schema.sql
|
||||
|
||||
|
||||
echo "Database initialized successfully."
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
console.log('Backend starting...');
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;`
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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?
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
@@ -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;"]
|
||||
@@ -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</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+3208
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 (
|
||||
<div className="flex flex-col h-screen bg-bg0 text-text overflow-hidden">
|
||||
<KeyboardListener />
|
||||
<TopBar onToggleCommandPalette={togglePalette} />
|
||||
<div className="relative flex flex-1 overflow-hidden">
|
||||
<NavRail />
|
||||
<main className="flex-1 overflow-y-auto p-4 pb-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
{inspector && <Inspector mode={inspector.mode} id={inspector.id} onClose={closeInspector} />}
|
||||
{queueOpen && <NowPlayingPanel onClose={() => setQueueOpen(false)} />}
|
||||
{lyricsOpen && <LyricsOverlay onClose={() => setLyricsOpen(false)} />}
|
||||
</div>
|
||||
<PlaybackBar
|
||||
queueOpen={queueOpen}
|
||||
lyricsOpen={lyricsOpen}
|
||||
onToggleQueue={() => setQueueOpen((o) => !o)}
|
||||
onToggleLyrics={() => setLyricsOpen((o) => !o)}
|
||||
/>
|
||||
<AudioEngine />
|
||||
<Toaster />
|
||||
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, TrackArtist>();
|
||||
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<string>();
|
||||
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 <span className={className}>{fallback || 'Unknown artist'}</span>;
|
||||
}
|
||||
const unique = deduplicateArtists(artists);
|
||||
return (
|
||||
<span className={className}>
|
||||
{unique.map((a, i) => (
|
||||
<span key={a.id}>
|
||||
{i > 0 && <span className="opacity-60">{a.role === 'featured' && unique[i - 1].role !== 'featured' ? ' feat. ' : ', '}</span>}
|
||||
<Link
|
||||
to="/artists/$artistId"
|
||||
params={{ artistId: a.id }}
|
||||
onClick={stopPropagation ? (e) => e.stopPropagation() : undefined}
|
||||
className={`hover:text-text hover:underline ${a.role === 'featured' ? 'opacity-75' : ''}`}
|
||||
>
|
||||
{a.name}
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 <img> 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 (
|
||||
<div className={`relative overflow-hidden ${r} ${className}`} style={{ background: gradient }}>
|
||||
{!loaded && <Music className="absolute inset-0 m-auto h-2/5 w-2/5 text-on-accent/20" strokeWidth={1.5} aria-hidden />}
|
||||
<img
|
||||
src={proxied}
|
||||
alt={seed}
|
||||
loading={eager ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setErrored(true)}
|
||||
className={`h-full w-full object-cover transition-opacity duration-300 ${loaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={`relative flex items-center justify-center overflow-hidden ${r} ${className}`} style={{ background: gradient }}>
|
||||
<Music className="h-2/5 w-2/5 text-on-accent/20" strokeWidth={1.5} aria-hidden />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <audio> element driven by the playback store.
|
||||
// State -> DOM via store subscriptions; DOM -> state via media events.
|
||||
export const AudioEngine = () => {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
// Track which id is currently loaded into the element, and whether it ended
|
||||
// naturally (so we record COMPLETED, not skip, on the resulting track change).
|
||||
const loadedIdRef = useRef<string | null>(null);
|
||||
const endedNaturallyRef = useRef(false);
|
||||
// Track whether the current track has crossed the completion threshold.
|
||||
const crossedThresholdRef = useRef(false);
|
||||
// Track whether we've already recorded a completed play for the current track
|
||||
// (to avoid double-recording when both threshold crossed AND ended fires).
|
||||
const recordedCompletedRef = useRef(false);
|
||||
|
||||
// --- DOM -> store: media events -----------------------------------------
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const store = usePlaybackStore.getState;
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
store().setPosition(audio.currentTime);
|
||||
// Mark as effectively completed if we cross the threshold.
|
||||
if (
|
||||
!crossedThresholdRef.current &&
|
||||
audio.duration &&
|
||||
audio.currentTime / audio.duration >= COMPLETION_THRESHOLD
|
||||
) {
|
||||
crossedThresholdRef.current = true;
|
||||
}
|
||||
};
|
||||
const onLoadedMetadata = () => {
|
||||
if (Number.isFinite(audio.duration)) store().setDuration(audio.duration);
|
||||
};
|
||||
const onPlay = () => {
|
||||
if (!store().isPlaying) store().play();
|
||||
};
|
||||
const onPause = () => {
|
||||
// Ignore the pause that fires as part of ending a track.
|
||||
if (audio.ended) return;
|
||||
if (store().isPlaying) store().pause();
|
||||
};
|
||||
const onEnded = () => {
|
||||
const trackId = loadedIdRef.current;
|
||||
if (trackId && !recordedCompletedRef.current) {
|
||||
endedNaturallyRef.current = true;
|
||||
recordedCompletedRef.current = true;
|
||||
try {
|
||||
void vibeService.feedback(trackId, 'completed').catch(() => {});
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
store().next();
|
||||
};
|
||||
|
||||
audio.addEventListener('timeupdate', onTimeUpdate);
|
||||
audio.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
audio.addEventListener('play', onPlay);
|
||||
audio.addEventListener('pause', onPause);
|
||||
audio.addEventListener('ended', onEnded);
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', onTimeUpdate);
|
||||
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
audio.removeEventListener('play', onPlay);
|
||||
audio.removeEventListener('pause', onPause);
|
||||
audio.removeEventListener('ended', onEnded);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// --- store -> DOM: react to currentTrack changes ------------------------
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const applyTrack = (id: string | null) => {
|
||||
if (id === loadedIdRef.current) return;
|
||||
|
||||
// The previously loaded track is changing. If it didn't end naturally and
|
||||
// hadn't crossed the completion threshold, record a skip (best-effort).
|
||||
// If it crossed the threshold OR ended naturally, record as completed.
|
||||
const prevId = loadedIdRef.current;
|
||||
const completed = endedNaturallyRef.current || crossedThresholdRef.current;
|
||||
if (prevId) {
|
||||
try {
|
||||
if (completed) {
|
||||
recordedCompletedRef.current = true;
|
||||
void vibeService.feedback(prevId, 'completed').catch(() => {});
|
||||
} else {
|
||||
void vibeService.feedback(prevId, 'skipped').catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
endedNaturallyRef.current = false;
|
||||
crossedThresholdRef.current = false;
|
||||
recordedCompletedRef.current = false;
|
||||
loadedIdRef.current = id;
|
||||
|
||||
if (!id) {
|
||||
audio.removeAttribute('src');
|
||||
audio.load();
|
||||
return;
|
||||
}
|
||||
|
||||
audio.src = trackService.getStreamUrl(id);
|
||||
audio.load();
|
||||
if (usePlaybackStore.getState().isPlaying) {
|
||||
void audio.play().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
// Apply the current value immediately, then subscribe to future changes.
|
||||
applyTrack(usePlaybackStore.getState().currentTrack?.id ?? null);
|
||||
const unsub = usePlaybackStore.subscribe((state) => {
|
||||
applyTrack(state.currentTrack?.id ?? null);
|
||||
});
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
// --- store -> DOM: isPlaying ------------------------------------------------
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const apply = (isPlaying: boolean) => {
|
||||
if (isPlaying) {
|
||||
if (audio.paused) void audio.play().catch(() => {});
|
||||
} else {
|
||||
if (!audio.paused) audio.pause();
|
||||
}
|
||||
};
|
||||
|
||||
apply(usePlaybackStore.getState().isPlaying);
|
||||
return usePlaybackStore.subscribe((state) => apply(state.isPlaying));
|
||||
}, []);
|
||||
|
||||
// --- store -> DOM: volume --------------------------------------------------
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const apply = (volume: number) => {
|
||||
audio.volume = Math.min(1, Math.max(0, volume));
|
||||
};
|
||||
|
||||
apply(usePlaybackStore.getState().volume);
|
||||
return usePlaybackStore.subscribe((state) => apply(state.volume));
|
||||
}, []);
|
||||
|
||||
// --- store -> DOM: external seeks (user scrubbing) -------------------------
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const apply = (position: number) => {
|
||||
if (Math.abs(audio.currentTime - position) > SEEK_THRESHOLD) {
|
||||
audio.currentTime = position;
|
||||
}
|
||||
};
|
||||
|
||||
return usePlaybackStore.subscribe((state) => apply(state.position));
|
||||
}, []);
|
||||
|
||||
// --- MediaSession: hardware media keys + OS media controls ------------------
|
||||
//
|
||||
// Without this, the browser's default media-key handler toggles the <audio>
|
||||
// element directly, bypassing the store — causing the UI and audio to
|
||||
// desync. By registering action handlers we route all media-key input
|
||||
// through the store, so isPlaying stays consistent. We also publish track
|
||||
// metadata so the OS "now playing" widget shows title/artist/artwork.
|
||||
useEffect(() => {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
|
||||
const store = usePlaybackStore.getState;
|
||||
|
||||
const handlers: Partial<Record<MediaSessionAction, (details: MediaSessionActionDetails) => void>> = {
|
||||
play: () => store().play(),
|
||||
pause: () => store().pause(),
|
||||
previoustrack: () => store().prev(),
|
||||
nexttrack: () => store().next(),
|
||||
seekbackward: (details) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const delta = details.seekOffset ?? SEEK_INCREMENT;
|
||||
audio.currentTime = Math.max(0, audio.currentTime - delta);
|
||||
},
|
||||
seekforward: (details) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const delta = details.seekOffset ?? SEEK_INCREMENT;
|
||||
audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + delta);
|
||||
},
|
||||
seekto: (details) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || details.seekTime == null) return;
|
||||
audio.currentTime = details.seekTime;
|
||||
},
|
||||
stop: () => {
|
||||
store().pause();
|
||||
store().setPosition(0);
|
||||
},
|
||||
};
|
||||
|
||||
for (const [action, handler] of Object.entries(handlers)) {
|
||||
try {
|
||||
navigator.mediaSession.setActionHandler(
|
||||
action as MediaSessionAction,
|
||||
handler ?? null,
|
||||
);
|
||||
} catch {
|
||||
// Some actions aren't supported on every browser/OS — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up handlers on unmount so they don't outlive the engine.
|
||||
return () => {
|
||||
for (const action of Object.keys(handlers)) {
|
||||
try {
|
||||
navigator.mediaSession.setActionHandler(
|
||||
action as MediaSessionAction,
|
||||
null,
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// --- MediaSession: publish metadata + playback state ------------------------
|
||||
useEffect(() => {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
|
||||
const update = (track: Track | null, isPlaying: boolean) => {
|
||||
if (track) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: track.title || 'Unknown',
|
||||
artist: track.artist || 'Unknown',
|
||||
album: '',
|
||||
artwork: buildArtwork(track),
|
||||
});
|
||||
}
|
||||
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
|
||||
};
|
||||
|
||||
// Publish immediately for the current state.
|
||||
update(usePlaybackStore.getState().currentTrack, usePlaybackStore.getState().isPlaying);
|
||||
|
||||
// Subscribe to future changes of currentTrack or isPlaying.
|
||||
return usePlaybackStore.subscribe((state) => {
|
||||
update(state.currentTrack, state.isPlaying);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <audio ref={audioRef} hidden />;
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
interface BackLinkProps {
|
||||
/** Fallback destination if there's no browser history to go back to (e.g. deep-linked). */
|
||||
to: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-history-aware back affordance. Prefers `history.back()` when the
|
||||
* router has a previous entry (so a user who deep-linked to an album from
|
||||
* Search returns to Search, not to the Albums index); falls back to a
|
||||
* normal `<Link>` for direct entries.
|
||||
*
|
||||
* Detail pages used to hard-code `<Link to="/albums">` which broke that
|
||||
* mental model — this fixes it across AlbumDetail / ArtistDetail / Genres.
|
||||
*/
|
||||
export function BackLink({ to, label }: BackLinkProps) {
|
||||
// window.history.length === 1 means this tab was opened directly to the
|
||||
// current URL — there's nothing to go back to, so render a real link.
|
||||
const canGoBack = typeof window !== 'undefined' && window.history.length > 1;
|
||||
if (canGoBack) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.history.back()}
|
||||
className="inline-flex items-center gap-1 text-sm text-muted hover:text-text transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} /> {label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="inline-flex items-center gap-1 text-sm text-muted hover:text-text transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} /> {label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
Search,
|
||||
Music,
|
||||
Disc3,
|
||||
Users,
|
||||
Tag,
|
||||
Zap,
|
||||
Compass,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
Terminal,
|
||||
Home,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface CommandItem {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon: LucideIcon;
|
||||
action: () => void;
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
const NAV_COMMANDS: CommandItem[] = [
|
||||
{ id: 'nav-home', label: 'Home', icon: Home, action: () => {}, keywords: ['dashboard', 'start'] },
|
||||
{ id: 'nav-tracks', label: 'Songs', description: 'Browse all tracks', icon: Music, action: () => {}, keywords: ['tracks', 'music', 'songs'] },
|
||||
{ id: 'nav-albums', label: 'Albums', icon: Disc3, action: () => {}, keywords: ['albums', 'records'] },
|
||||
{ id: 'nav-artists', label: 'Artists', icon: Users, action: () => {}, keywords: ['artists', 'bands'] },
|
||||
{ id: 'nav-genres', label: 'Genres', icon: Tag, action: () => {}, keywords: ['genres', 'tags', 'categories'] },
|
||||
{ id: 'nav-vibe', label: 'Vibe', description: 'Endless recommendations', icon: Zap, action: () => {}, keywords: ['vibe', 'recommendations', 'radio'] },
|
||||
{ id: 'nav-discover', label: 'Discover', description: 'Browse by genre', icon: Compass, action: () => {}, keywords: ['discover', 'explore'] },
|
||||
{ id: 'nav-quarantine', label: 'Quarantine', icon: ShieldAlert, action: () => {}, keywords: ['quarantine', 'disliked', 'trash'] },
|
||||
{ id: 'nav-jobs', label: 'Jobs', description: 'Background tasks', icon: Terminal, action: () => {}, keywords: ['jobs', 'tasks', 'queue'] },
|
||||
{ id: 'nav-settings', label: 'Settings', icon: Settings, action: () => {}, keywords: ['settings', 'preferences', 'config'] },
|
||||
];
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
||||
const navigate = useNavigate();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
// Bind navigation to each command action
|
||||
const commands = useMemo(
|
||||
() =>
|
||||
NAV_COMMANDS.map((cmd) => ({
|
||||
...cmd,
|
||||
action: () => {
|
||||
const pathMap: Record<string, string> = {
|
||||
'nav-home': '/',
|
||||
'nav-tracks': '/tracks',
|
||||
'nav-albums': '/albums',
|
||||
'nav-artists': '/artists',
|
||||
'nav-genres': '/genres',
|
||||
'nav-vibe': '/vibe',
|
||||
'nav-discover': '/discover',
|
||||
'nav-quarantine': '/quarantine',
|
||||
'nav-jobs': '/jobs',
|
||||
'nav-settings': '/settings',
|
||||
};
|
||||
const path = pathMap[cmd.id] ?? '/';
|
||||
void navigate({ to: path as any });
|
||||
onClose();
|
||||
},
|
||||
})),
|
||||
[navigate, onClose],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.toLowerCase().trim();
|
||||
if (!q) return commands;
|
||||
return commands.filter(
|
||||
(cmd) =>
|
||||
cmd.label.toLowerCase().includes(q) ||
|
||||
cmd.keywords?.some((kw) => kw.includes(q)) ||
|
||||
cmd.description?.toLowerCase().includes(q),
|
||||
);
|
||||
}, [query, commands]);
|
||||
|
||||
// Reset search when opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('');
|
||||
setSelectedIndex(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (!listRef.current) return;
|
||||
const el = listRef.current.children[selectedIndex] as HTMLElement | undefined;
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}, [selectedIndex]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.max(i - 1, 0));
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (filtered[selectedIndex]) {
|
||||
filtered[selectedIndex].action();
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[filtered, selectedIndex, onClose],
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Dialog */}
|
||||
<div className="fixed left-1/2 top-[15vh] z-50 w-full max-w-lg -translate-x-1/2 animate-rise">
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-bg1 shadow-2xl shadow-black/60">
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||
<Search size={16} className="text-muted flex-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setSelectedIndex(0);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search pages, commands…"
|
||||
className="flex-1 bg-transparent text-sm text-text placeholder:text-muted outline-none"
|
||||
/>
|
||||
<kbd className="flex-none rounded border border-border bg-surface0 px-1.5 py-0.5 text-[11px] font-medium text-muted">
|
||||
Esc
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div ref={listRef} className="max-h-72 overflow-y-auto py-1.5" role="listbox">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-sm text-muted">
|
||||
No matching pages
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((cmd, i) => (
|
||||
<button
|
||||
key={cmd.id}
|
||||
onClick={cmd.action}
|
||||
role="option"
|
||||
aria-selected={i === selectedIndex}
|
||||
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors ${
|
||||
i === selectedIndex
|
||||
? 'bg-accent/10 text-accent'
|
||||
: 'text-text hover:bg-surface0'
|
||||
}`}
|
||||
>
|
||||
<cmd.icon
|
||||
size={16}
|
||||
className={
|
||||
i === selectedIndex ? 'text-accent' : 'text-muted'
|
||||
}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{cmd.label}</div>
|
||||
{cmd.description && (
|
||||
<div className="truncate text-xs text-muted">
|
||||
{cmd.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ArrowRight
|
||||
size={14}
|
||||
className={
|
||||
i === selectedIndex ? 'text-accent' : 'text-muted/0'
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="border-t border-border px-4 py-2 text-[11px] text-muted flex items-center gap-3">
|
||||
<span>
|
||||
<kbd className="rounded border border-border bg-surface0 px-1 font-medium">↑↓</kbd> Navigate
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded border border-border bg-surface0 px-1 font-medium">↵</kbd> Open
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Re-export from the Ethos component library.
|
||||
* All existing imports continue to work.
|
||||
*/
|
||||
export { EmptyState } from './ethos/EmptyState';
|
||||
@@ -0,0 +1,169 @@
|
||||
import { X, Play } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import type { AlbumWithTracks, ArtistWithAlbums } from '../types';
|
||||
import { albumService } from '../services/albumService';
|
||||
import { artistService } from '../services/artistService';
|
||||
import { Artwork } from './Artwork';
|
||||
import { Button } from './ethos/Button';
|
||||
import { TrackRow } from './TrackRow';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
|
||||
type InspectorMode = 'album' | 'artist' | 'track';
|
||||
|
||||
interface InspectorProps {
|
||||
mode: InspectorMode;
|
||||
id: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function AlbumInspector({ id, onClose }: { id: string; onClose: () => void }) {
|
||||
const { setQueue, playTrack } = usePlaybackStore();
|
||||
const { data, isLoading } = useQuery<AlbumWithTracks>({
|
||||
queryKey: ['album', id],
|
||||
queryFn: () => albumService.getAlbum(id),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="skeleton h-40 w-full rounded" />
|
||||
<div className="skeleton h-4 w-2/3" />
|
||||
<div className="skeleton h-3 w-1/3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const tracks = data.tracks ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted">Album</span>
|
||||
<button onClick={onClose} className="text-muted hover:text-text p-0.5 rounded">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{/* Artwork + meta */}
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="w-full aspect-square rounded-md overflow-hidden">
|
||||
<Artwork seed={data.title} src={data.artwork_id} className="w-full h-full" rounded="md" eager />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text truncate">{data.title}</h2>
|
||||
<p className="text-xs text-secondary">{data.artist_name || 'Unknown artist'}{data.year ? ` · ${data.year}` : ''}</p>
|
||||
<p className="text-xs text-muted mt-0.5">{tracks.length} tracks</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Play size={14} fill="currentColor" />}
|
||||
onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }}
|
||||
disabled={!tracks.length}
|
||||
className="w-full"
|
||||
>
|
||||
Play album
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Track list */}
|
||||
<div className="border-t border-border">
|
||||
<div className="px-4 py-2 text-[10px] font-semibold uppercase tracking-wider text-muted">Tracks</div>
|
||||
<div className="space-y-0.5 px-2 pb-3">
|
||||
{tracks.map((t, i) => (
|
||||
<TrackRow key={t.id} track={t} queue={tracks} index={i} showActions={false} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtistInspector({ id, onClose }: { id: string; onClose: () => void }) {
|
||||
const { data, isLoading } = useQuery<ArtistWithAlbums>({
|
||||
queryKey: ['artist', id],
|
||||
queryFn: () => artistService.getArtist(id),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="skeleton h-32 w-32 rounded-full mx-auto" />
|
||||
<div className="skeleton h-4 w-1/2 mx-auto" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const albums = data.albums ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted">Artist</span>
|
||||
<button onClick={onClose} className="text-muted hover:text-text p-0.5 rounded">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1">
|
||||
<div className="p-4 space-y-3 text-center">
|
||||
<div className="w-24 h-24 rounded-full overflow-hidden mx-auto ring-2 ring-border">
|
||||
<Artwork seed={data.name} src={data.image_path} className="w-full h-full" rounded="full" eager />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text">{data.name}</h2>
|
||||
<p className="text-xs text-muted">{albums.length} albums</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{albums.length > 0 && (
|
||||
<div className="border-t border-border">
|
||||
<div className="px-4 py-2 text-[10px] font-semibold uppercase tracking-wider text-muted">Albums</div>
|
||||
<div className="grid grid-cols-3 gap-2 p-2">
|
||||
{albums.map((album) => (
|
||||
<Link
|
||||
key={album.id}
|
||||
to="/albums/$albumId"
|
||||
params={{ albumId: album.id }}
|
||||
className="flex flex-col gap-1 rounded-md p-1.5 hover:bg-surface0 transition-colors"
|
||||
>
|
||||
<div className="aspect-square rounded-sm overflow-hidden">
|
||||
<Artwork seed={album.title} src={album.artwork_id} className="w-full h-full" />
|
||||
</div>
|
||||
<span className="text-xs text-text truncate">{album.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type { InspectorMode };
|
||||
|
||||
/**
|
||||
* Inspector panel — right-side detail view for albums, artists, and tracks.
|
||||
* Replaces full-page navigations with a slide-in panel per Ethos conventions.
|
||||
*/
|
||||
export function Inspector({ mode, id, onClose }: InspectorProps) {
|
||||
return (
|
||||
<aside className="w-80 flex flex-col border-l border-border bg-bg1 overflow-hidden shrink-0 animate-slide-in">
|
||||
{mode === 'album' && <AlbumInspector id={id} onClose={onClose} />}
|
||||
{mode === 'artist' && <ArtistInspector id={id} onClose={onClose} />}
|
||||
{mode === 'track' && (
|
||||
<div className="p-4 text-sm text-muted text-center py-10">
|
||||
Track inspector coming soon
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Re-export from the Ethos component library.
|
||||
* All existing imports continue to work.
|
||||
*
|
||||
* Note: `LoadingState` (spinner) has been replaced by Skeletons.
|
||||
* The export is preserved for backward compatibility.
|
||||
*/
|
||||
export { Skeleton, SkeletonRows, SkeletonGrid } from './ethos/Skeleton';
|
||||
|
||||
interface LoadingStateProps {
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spinner-based loading — use only for unknown-duration waits.
|
||||
* Most pages should use SkeletonRows/SkeletonGrid instead.
|
||||
*/
|
||||
export function LoadingState({ label = 'Loading…', className = '' }: LoadingStateProps) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center gap-2 py-10 text-sm text-muted ${className}`}>
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { X } from 'lucide-react';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { trackService } from '../services/trackService';
|
||||
import { albumService } from '../services/albumService';
|
||||
import { Artwork } from './Artwork';
|
||||
import { ArtistLinks } from './ArtistLinks';
|
||||
import { SyncedLyrics } from './SyncedLyrics';
|
||||
|
||||
/**
|
||||
* Dedicated, roomy lyrics view — overlays the content area (not the playback
|
||||
* bar, so transport stays usable). Separated from the queue sidebar so the
|
||||
* karaoke lyrics get the space they deserve.
|
||||
*/
|
||||
export function LyricsOverlay({ onClose }: { onClose: () => void }) {
|
||||
const currentTrack = usePlaybackStore((s) => s.currentTrack);
|
||||
|
||||
const albumQ = useQuery({
|
||||
queryKey: ['album', currentTrack?.album_id],
|
||||
queryFn: () => albumService.getAlbum(currentTrack!.album_id),
|
||||
enabled: !!currentTrack?.album_id,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const lyricsQ = useQuery({
|
||||
queryKey: ['lyrics', currentTrack?.id],
|
||||
queryFn: () => trackService.getLyrics(currentTrack!.id),
|
||||
enabled: !!currentTrack,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-40 glass flex flex-col animate-rise">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 px-6 py-4 border-b border-border/70">
|
||||
{currentTrack && (
|
||||
<div className="w-12 h-12 flex-none rounded-lg overflow-hidden shadow-md shadow-black/40">
|
||||
<Artwork seed={`${currentTrack.title} ${currentTrack.artist}`} src={albumQ.data?.artwork_id} className="w-full h-full" rounded="lg" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-lg font-bold text-text truncate">{currentTrack?.title ?? 'Lyrics'}</div>
|
||||
{currentTrack && (
|
||||
<ArtistLinks artists={currentTrack.artists} fallback={currentTrack.artist} className="block text-sm text-muted truncate" />
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} aria-label="Close lyrics" className="rounded-md p-2 text-muted hover:text-text hover:bg-surface1 transition-colors">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Lyrics */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto mx-auto w-full max-w-2xl"
|
||||
style={{
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, transparent 0, #000 10%, #000 90%, transparent 100%)',
|
||||
maskImage: 'linear-gradient(to bottom, transparent 0, #000 10%, #000 90%, transparent 100%)',
|
||||
}}
|
||||
>
|
||||
{!currentTrack ? (
|
||||
<p className="px-6 py-10 text-center text-sm text-muted italic">Nothing playing.</p>
|
||||
) : lyricsQ.isLoading ? (
|
||||
<p className="px-6 py-10 text-center text-sm text-muted">Loading…</p>
|
||||
) : lyricsQ.isError || !lyricsQ.data ? (
|
||||
<p className="px-6 py-10 text-center text-sm text-muted italic">No lyrics available.</p>
|
||||
) : (
|
||||
<SyncedLyrics synced={lyricsQ.data.synced_lyrics} plain={lyricsQ.data.lyrics_text} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Play } from 'lucide-react';
|
||||
import { Artwork } from './Artwork';
|
||||
|
||||
interface MediaCardProps {
|
||||
seed: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
artSrc?: string | null;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function MediaCard({ seed, title, subtitle, artSrc, onClick }: MediaCardProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="card-surface group text-left w-full"
|
||||
>
|
||||
<div className="artwork-frame relative w-full">
|
||||
<Artwork seed={seed} src={artSrc} className="w-full h-full transition-transform duration-500 group-hover:scale-105" rounded="xl" />
|
||||
<div className="play-overlay">
|
||||
<div className="play-overlay-btn">
|
||||
<Play size={18} fill="currentColor" className="ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-text">{title}</div>
|
||||
{subtitle && <div className="truncate text-xs text-muted mt-0.5">{subtitle}</div>}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
Home, Music, Disc3, Users, Tag, Compass,
|
||||
Terminal, ShieldAlert,
|
||||
Zap,
|
||||
Settings, Sparkles,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
exact?: boolean;
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
label: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: 'Workspace',
|
||||
items: [
|
||||
{ to: '/', icon: Home, label: 'Home', exact: true },
|
||||
{ to: '/tracks', icon: Music, label: 'Songs' },
|
||||
{ to: '/albums', icon: Disc3, label: 'Albums' },
|
||||
{ to: '/artists', icon: Users, label: 'Artists' },
|
||||
{ to: '/genres', icon: Tag, label: 'Genres' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'AI',
|
||||
items: [
|
||||
{ to: '/vibe', icon: Zap, label: 'Vibe' },
|
||||
{ to: '/discover', icon: Compass, label: 'Discover' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Infrastructure',
|
||||
items: [
|
||||
{ to: '/jobs', icon: Terminal, label: 'Jobs' },
|
||||
{ to: '/quarantine', icon: ShieldAlert, label: 'Quarantine' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
items: [
|
||||
{ to: '/settings', icon: Settings, label: 'Settings' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const base =
|
||||
'group relative flex items-center gap-2.5 px-3 py-1.5 rounded-md text-xs w-full transition-all duration-100';
|
||||
const inactive = 'text-secondary hover:bg-surface0 hover:text-text';
|
||||
const active =
|
||||
'bg-accent/10 text-accent font-medium ' +
|
||||
"before:content-[''] before:absolute before:left-0 before:top-1 before:bottom-1 before:w-0.5 before:rounded-full before:bg-accent";
|
||||
|
||||
export function NavRail() {
|
||||
return (
|
||||
<aside className="w-48 flex flex-col bg-bg1 border-r border-border shrink-0 overflow-y-auto">
|
||||
{/* App branding */}
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-accent text-on-accent">
|
||||
<Sparkles size={14} />
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-text tracking-tight">muzick</span>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-2 space-y-4 pb-3 pt-3">
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="px-3 mb-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-disabled">
|
||||
{group.label}
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
{group.items.map(({ to, icon: Icon, label, exact }) => (
|
||||
<li key={to}>
|
||||
<Link
|
||||
to={to}
|
||||
activeOptions={{ exact: exact ?? false }}
|
||||
activeProps={{ className: `${base} ${active}` }}
|
||||
inactiveProps={{ className: `${base} ${inactive}` }}
|
||||
>
|
||||
<Icon size={15} className="flex-none transition-transform group-hover:scale-110" />
|
||||
<span className="truncate">{label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-3 py-2 text-[10px] text-disabled border-t border-border">
|
||||
muzick · v0.1
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { X, Play, Pause, SkipBack, SkipForward, Disc3 } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { Artwork } from './Artwork';
|
||||
import { ArtistLinks } from './ArtistLinks';
|
||||
import { TrackRow, formatDuration } from './TrackRow';
|
||||
import { albumService } from '../services/albumService';
|
||||
|
||||
export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
|
||||
const { currentTrack, queue, isPlaying, position, duration, play, pause, next, prev, setPosition } = usePlaybackStore();
|
||||
|
||||
const currentIdx = currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1;
|
||||
const upNext = currentIdx >= 0 ? queue.slice(currentIdx + 1) : queue;
|
||||
|
||||
const albumQ = useQuery({
|
||||
queryKey: ['album', currentTrack?.album_id],
|
||||
queryFn: () => albumService.getAlbum(currentTrack!.album_id),
|
||||
enabled: !!currentTrack?.album_id,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const artwork = albumQ.data?.artwork_id ?? currentTrack?.artwork_id ?? null;
|
||||
|
||||
return (
|
||||
<aside className="w-96 flex flex-col border-l border-border/70 bg-bg1/80 backdrop-blur-sm overflow-hidden shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border/70">
|
||||
<span className="text-sm font-semibold text-text">Now Playing</span>
|
||||
<button onClick={onClose} className="text-muted hover:text-text p-1 rounded">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{currentTrack?.album_id ? (
|
||||
<Link to="/albums/$albumId" params={{ albumId: currentTrack.album_id }}
|
||||
className="group block aspect-square rounded-xl overflow-hidden relative shadow-lg shadow-black/40" title="Go to album">
|
||||
<Artwork seed={`${currentTrack.title} ${currentTrack.artist}`} src={artwork} className="w-full h-full transition-transform group-hover:scale-105" rounded="xl" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/40 transition-colors">
|
||||
<Disc3 size={28} className="text-on-accent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="aspect-square rounded-xl overflow-hidden shadow-lg shadow-black/40">
|
||||
<Artwork seed={currentTrack ? `${currentTrack.title} ${currentTrack.artist}` : 'empty'} src={artwork} className="w-full h-full" rounded="xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentTrack ? (
|
||||
<div className="text-center space-y-0.5">
|
||||
{currentTrack.album_id ? (
|
||||
<Link to="/albums/$albumId" params={{ albumId: currentTrack.album_id }} className="font-bold text-text truncate block hover:underline">
|
||||
{currentTrack.title}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="font-bold text-text truncate">{currentTrack.title}</div>
|
||||
)}
|
||||
<ArtistLinks artists={currentTrack.artists} fallback={currentTrack.artist} className="block text-sm text-muted truncate" />
|
||||
</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 cursor-pointer"
|
||||
/>
|
||||
<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-text"><SkipBack size={20} /></button>
|
||||
<button
|
||||
onClick={() => isPlaying ? pause() : play()}
|
||||
disabled={!currentTrack}
|
||||
className="transport-btn"
|
||||
>
|
||||
{isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
|
||||
</button>
|
||||
<button onClick={next} className="text-muted hover:text-text"><SkipForward size={20} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2 border-t border-border/70 text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Up Next ({upNext.length})
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||
{upNext.length === 0 ? (
|
||||
<div className="px-2 py-4 text-sm text-muted italic">Queue is empty.</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{upNext.map((track, i) => (
|
||||
<TrackRow
|
||||
key={`${track.id}-${i}`}
|
||||
track={track}
|
||||
queue={upNext.slice(i)}
|
||||
index={0}
|
||||
showActions={false}
|
||||
variant="compact"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type ContainerWidth = 'sm' | 'md' | 'lg' | 'full';
|
||||
|
||||
interface PageContainerProps {
|
||||
children: ReactNode;
|
||||
/** Controls max-width. sm → max-w-2xl, md → max-w-3xl (default), lg → max-w-5xl, full → no constraint. */
|
||||
width?: ContainerWidth;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const WIDTH_CLASSES: Record<ContainerWidth, string> = {
|
||||
sm: 'max-w-2xl',
|
||||
md: 'max-w-3xl',
|
||||
lg: 'max-w-5xl',
|
||||
full: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Ethos page container — enforces consistent horizontal centering and
|
||||
* vertical spacing so every page opens the same way.
|
||||
*
|
||||
* Previously every page hand-rolled its own `mx-auto space-y-* max-w-*`.
|
||||
*/
|
||||
export function PageContainer({ children, width = 'md', className = '' }: PageContainerProps) {
|
||||
return (
|
||||
<div className={`mx-auto space-y-6 ${WIDTH_CLASSES[width]} ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface PageHeaderProps {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** Optional right-aligned actions (buttons, toggles, etc.). */
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consistent page heading: a gradient title with an optional accent icon chip,
|
||||
* subtitle, and right-aligned action slot. Used across the library pages so
|
||||
* every screen opens the same way.
|
||||
*/
|
||||
export function PageHeader({ icon: Icon, title, subtitle, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-end justify-between gap-4 animate-rise">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
{Icon && (
|
||||
<div className="flex h-12 w-12 flex-none items-center justify-center rounded-2xl bg-accent/15 text-accent ring-1 ring-accent/25 shadow-lg shadow-accent/10">
|
||||
<Icon size={24} />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-gradient truncate text-3xl font-extrabold tracking-tight sm:text-4xl">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && <p className="mt-1 truncate text-sm text-muted">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex flex-none items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface PaginationProps {
|
||||
page: number;
|
||||
/** Whether a next page exists (typically `pageResults.length === PAGE_SIZE`). */
|
||||
hasNext: boolean;
|
||||
/** Disables both buttons while the next page is still placeholder data. */
|
||||
isLoading?: boolean;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prev/Next pager with a page indicator. Extracted from the duplicated
|
||||
* implementation in Tracks/Albums/Artists/Genres so the controls stay
|
||||
* consistent and accessible (aria-labels, disabled semantics) across pages.
|
||||
*/
|
||||
export function Pagination({ page, hasNext, isLoading, onPrev, onNext }: PaginationProps) {
|
||||
const prevDisabled = page === 0 || isLoading;
|
||||
const nextDisabled = !hasNext || isLoading;
|
||||
return (
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={prevDisabled}
|
||||
aria-label="Previous page"
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-border px-3 py-1.5 text-sm text-text hover:bg-surface1 disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
>
|
||||
<ChevronLeft size={16} /> Prev
|
||||
</button>
|
||||
<span className="text-sm text-muted tabular-nums" aria-live="polite">Page {page + 1}</span>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={nextDisabled}
|
||||
aria-label="Next page"
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-border px-3 py-1.5 text-sm text-text hover:bg-surface1 disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
>
|
||||
Next <ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface PanelHeaderProps {
|
||||
title: string;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
/**
|
||||
* Title styling intent:
|
||||
* - `'panel'` (default) — text-xs uppercase muted (Inspector, LyricsOverlay)
|
||||
* - `'heading'` — text-sm semibold text-text (NowPlayingPanel)
|
||||
*/
|
||||
intent?: 'panel' | 'heading';
|
||||
}
|
||||
|
||||
const TITLE_CLASSES = {
|
||||
panel: 'text-xs font-semibold uppercase tracking-wider text-muted',
|
||||
heading: 'text-sm font-semibold text-text',
|
||||
};
|
||||
|
||||
/**
|
||||
* Overlay/panel header bar — title label with an optional close button.
|
||||
* Standardizes the pattern that was hand-rolled in Inspector (×2),
|
||||
* NowPlayingPanel, LyricsOverlay, CommandPalette, and more.
|
||||
*/
|
||||
export function PanelHeader({ title, onClose, className = '', intent = 'panel' }: PanelHeaderProps) {
|
||||
return (
|
||||
<div className={`flex items-center justify-between px-4 py-2.5 border-b border-border ${className}`}>
|
||||
<span className={TITLE_CLASSES[intent]}>{title}</span>
|
||||
{onClose && (
|
||||
<button onClick={onClose} className="text-muted hover:text-text p-0.5 rounded">
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Play, Pause, SkipBack, SkipForward, Volume2, ListMusic, Shuffle, Repeat, Repeat1, MicVocal, ThumbsDown } from 'lucide-react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useDislikeTrack } from '../hooks/useDislikeTrack';
|
||||
import { Artwork } from './Artwork';
|
||||
import { ArtistLinks } from './ArtistLinks';
|
||||
import { formatDuration } from './TrackRow';
|
||||
|
||||
interface PlaybackBarProps {
|
||||
queueOpen: boolean;
|
||||
lyricsOpen: boolean;
|
||||
onToggleQueue: () => void;
|
||||
onToggleLyrics: () => void;
|
||||
}
|
||||
|
||||
export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyrics }: PlaybackBarProps) {
|
||||
const { currentTrack, isPlaying, position, duration, volume, shuffle, repeat, play, pause, next, prev, setPosition, setVolume, toggleShuffle, cycleRepeat } = usePlaybackStore();
|
||||
const dislikeTrack = useDislikeTrack();
|
||||
|
||||
const handleDislike = () => {
|
||||
if (!currentTrack) return;
|
||||
dislikeTrack(currentTrack.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass h-20 border-t border-border/70 px-4 flex items-center gap-4 shrink-0 z-20">
|
||||
{/* Track info */}
|
||||
<div className="flex items-center gap-3 w-64 min-w-0 shrink-0">
|
||||
{currentTrack ? (
|
||||
<>
|
||||
{currentTrack.album_id ? (
|
||||
<Link to="/albums/$albumId" params={{ albumId: currentTrack.album_id }}
|
||||
className="w-12 h-12 flex-none rounded-lg overflow-hidden shadow-md shadow-black/40 ring-1 ring-border/50 hover:ring-accent/50 transition-shadow" title="Go to album">
|
||||
<Artwork seed={`${currentTrack.title} ${currentTrack.artist}`} src={currentTrack.artwork_id} className="w-full h-full" rounded="lg" eager />
|
||||
</Link>
|
||||
) : (
|
||||
<div className="w-12 h-12 flex-none rounded-lg overflow-hidden shadow-md shadow-black/40 ring-1 ring-border/50">
|
||||
<Artwork seed={`${currentTrack.title} ${currentTrack.artist}`} src={currentTrack.artwork_id} className="w-full h-full" rounded="lg" eager />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{currentTrack.album_id ? (
|
||||
<Link to="/albums/$albumId" params={{ albumId: currentTrack.album_id }}
|
||||
className="block text-sm font-semibold text-text truncate hover:underline">
|
||||
{currentTrack.title}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="text-sm font-semibold text-text truncate">{currentTrack.title}</div>
|
||||
)}
|
||||
<ArtistLinks artists={currentTrack.artists} fallback={currentTrack.artist} className="block text-xs text-muted truncate" />
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDislike}
|
||||
title="Dislike (sends to quarantine)"
|
||||
className="flex-none rounded-md p-1.5 text-muted hover:bg-surface1 hover:text-red-400 transition-colors"
|
||||
aria-label="Dislike — move to quarantine"
|
||||
>
|
||||
<ThumbsDown size={16} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<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-3">
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
className={`p-1.5 rounded-md transition-colors ${shuffle ? 'text-accent' : 'text-muted hover:text-text'}`}
|
||||
aria-label={shuffle ? 'Disable shuffle' : 'Enable shuffle'}
|
||||
title={shuffle ? 'Shuffle on' : 'Shuffle off'}
|
||||
>
|
||||
<Shuffle size={18} />
|
||||
</button>
|
||||
<button onClick={prev} className="text-muted hover:text-text" aria-label="Previous">
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => isPlaying ? pause() : play()}
|
||||
disabled={!currentTrack}
|
||||
className="transport-btn"
|
||||
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-text" aria-label="Next">
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={cycleRepeat}
|
||||
className={`p-1.5 rounded-md transition-colors ${repeat !== 'none' ? 'text-accent' : 'text-muted hover:text-text'}`}
|
||||
aria-label={`Repeat: ${repeat}`}
|
||||
title={repeat === 'none' ? 'Repeat off' : repeat === 'all' ? 'Repeat all' : 'Repeat one'}
|
||||
>
|
||||
{repeat === 'one' ? <Repeat1 size={18} /> : <Repeat size={18} />}
|
||||
</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"
|
||||
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"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
<button
|
||||
onClick={onToggleLyrics}
|
||||
disabled={!currentTrack}
|
||||
className={`p-2 rounded-md transition-colors disabled:opacity-30 ${lyricsOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text'}`}
|
||||
aria-label="Toggle lyrics"
|
||||
title="Lyrics"
|
||||
>
|
||||
<MicVocal size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleQueue}
|
||||
className={`p-2 rounded-md transition-colors ${queueOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text'}`}
|
||||
aria-label="Toggle queue panel"
|
||||
title="Up Next"
|
||||
>
|
||||
<ListMusic size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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-text">{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { parseLrc, activeLineIndex } from '../lib/lyrics';
|
||||
|
||||
interface SyncedLyricsProps {
|
||||
/** Raw LRC string from track_lyrics.synced_lyrics (may be null/unparseable). */
|
||||
synced: unknown;
|
||||
/** Plain lyrics fallback when there are no timestamped lines. */
|
||||
plain: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Karaoke-style lyrics. When time-synced lyrics are available it highlights the
|
||||
* line for the current playback position, dims the rest, smoothly auto-scrolls
|
||||
* to keep the active line centered, and lets you click any line to seek there.
|
||||
* Falls back to plain scrollable text when no sync data exists.
|
||||
*/
|
||||
export function SyncedLyrics({ synced, plain }: SyncedLyricsProps) {
|
||||
const position = usePlaybackStore((s) => s.position);
|
||||
const setPosition = usePlaybackStore((s) => s.setPosition);
|
||||
|
||||
const lines = useMemo(() => parseLrc(synced), [synced]);
|
||||
const active = activeLineIndex(lines, position);
|
||||
|
||||
const activeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Keep the active line centered as it changes.
|
||||
useEffect(() => {
|
||||
activeRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, [active]);
|
||||
|
||||
if (lines.length === 0) {
|
||||
if (!plain) {
|
||||
return (
|
||||
<p className="px-6 py-10 text-center text-sm text-muted italic">No lyrics available.</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<pre className="whitespace-pre-wrap px-6 py-6 text-center font-sans text-base leading-loose text-muted">
|
||||
{plain}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-5 py-[45%] space-y-5">
|
||||
{lines.map((line, i) => {
|
||||
const isActive = i === active;
|
||||
const isPast = i < active;
|
||||
// Lines near the active one stay a bit more legible than far ones.
|
||||
const distance = Math.abs(i - active);
|
||||
const upcomingOpacity = distance <= 2 ? 'opacity-70' : 'opacity-40';
|
||||
return (
|
||||
<button
|
||||
key={`${line.time}-${i}`}
|
||||
ref={isActive ? activeRef : undefined}
|
||||
onClick={() => setPosition(line.time)}
|
||||
className={`block w-full text-left text-2xl font-extrabold leading-tight tracking-tight transition-all duration-500 ease-out hover:text-text ${
|
||||
isActive
|
||||
? 'text-accent scale-[1.03] origin-left [text-shadow:0_0_24px_var(--accent)]'
|
||||
: isPast
|
||||
? 'text-muted/30'
|
||||
: `text-muted ${upcomingOpacity}`
|
||||
}`}
|
||||
>
|
||||
{line.text || '♪'}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { CheckCircle2, XCircle, Info, X } from 'lucide-react';
|
||||
import { useToastStore, type ToastKind } from '../store/useToastStore';
|
||||
|
||||
const ICONS: Record<ToastKind, LucideIcon> = {
|
||||
success: CheckCircle2,
|
||||
error: XCircle,
|
||||
info: Info,
|
||||
};
|
||||
|
||||
const ACCENT: Record<ToastKind, string> = {
|
||||
success: 'text-green',
|
||||
error: 'text-red',
|
||||
info: 'text-accent',
|
||||
};
|
||||
|
||||
/**
|
||||
* Fixed top-right toast stack. Ethos spec: notifications stack top-right.
|
||||
* Supports optional action (e.g. "Undo") and auto-dismiss.
|
||||
*/
|
||||
export function Toaster() {
|
||||
const toasts = useToastStore((s) => s.toasts);
|
||||
const dismiss = useToastStore((s) => s.dismiss);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && toasts.length > 0) {
|
||||
dismiss(toasts[toasts.length - 1].id);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [toasts, dismiss]);
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed top-14 right-4 z-50 flex flex-col gap-2 w-[min(20rem,calc(100vw-2rem))]"
|
||||
role="region"
|
||||
aria-label="Notifications"
|
||||
aria-live="polite"
|
||||
>
|
||||
{toasts.map((t) => {
|
||||
const Icon = ICONS[t.kind];
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className="flex items-start gap-2.5 rounded-md border border-border bg-bg1 p-3 shadow-lg shadow-black/40 animate-rise"
|
||||
role="status"
|
||||
>
|
||||
<Icon size={16} className={`mt-0.5 flex-none ${ACCENT[t.kind]}`} />
|
||||
<div className="min-w-0 flex-1 text-sm text-text">{t.message}</div>
|
||||
{t.action && (
|
||||
<button
|
||||
onClick={() => { t.action?.onClick(); dismiss(t.id); }}
|
||||
className="flex-none text-xs font-semibold text-accent hover:text-accent-h"
|
||||
>
|
||||
{t.action.label}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => dismiss(t.id)}
|
||||
aria-label="Dismiss notification"
|
||||
className="flex-none rounded p-0.5 text-muted hover:text-text hover:bg-surface0"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Search, X, Command, ChevronRight, Wifi, WifiOff } from 'lucide-react';
|
||||
import { useNavigate, useRouterState } from '@tanstack/react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchHealthStatus } from '../services/healthService';
|
||||
|
||||
interface TopBarProps {
|
||||
onToggleCommandPalette: () => void;
|
||||
}
|
||||
|
||||
/** Page title map for breadcrumbs */
|
||||
const PAGE_TITLES: Record<string, string> = {
|
||||
'/': 'Home',
|
||||
'/tracks': 'Songs',
|
||||
'/albums': 'Albums',
|
||||
'/artists': 'Artists',
|
||||
'/genres': 'Genres',
|
||||
'/vibe': 'Vibe',
|
||||
'/discover': 'Discover',
|
||||
'/search': 'Search',
|
||||
'/settings': 'Settings',
|
||||
'/quarantine': 'Quarantine',
|
||||
'/jobs': 'Jobs',
|
||||
};
|
||||
|
||||
function Breadcrumbs({ pathname }: { pathname: string }) {
|
||||
// Handle detail pages
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
|
||||
if (segments.length <= 1) {
|
||||
const title = PAGE_TITLES[pathname] ?? 'Muzick';
|
||||
return (
|
||||
<span className="text-sm font-medium text-text truncate">{title}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// For /albums/$id or /artists/$id
|
||||
const parentPath = `/${segments[0]}`;
|
||||
const parentTitle = PAGE_TITLES[parentPath] ?? segments[0];
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-sm min-w-0">
|
||||
<span className="text-secondary truncate">{parentTitle}</span>
|
||||
<ChevronRight size={12} className="text-muted flex-none" />
|
||||
<span className="text-text font-medium truncate">Details</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionStatus() {
|
||||
const { data, isError } = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: () => fetchHealthStatus(),
|
||||
refetchInterval: 30_000,
|
||||
staleTime: 10_000,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const healthy = data?.postgres === 'ok';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium ${
|
||||
healthy
|
||||
? 'text-green bg-green/10'
|
||||
: isError
|
||||
? 'text-red bg-red/10'
|
||||
: 'text-muted bg-surface0'
|
||||
}`}
|
||||
title={
|
||||
healthy
|
||||
? 'All systems healthy'
|
||||
: isError
|
||||
? 'Backend unreachable'
|
||||
: 'Checking…'
|
||||
}
|
||||
>
|
||||
{healthy ? (
|
||||
<Wifi size={12} className="text-green" />
|
||||
) : (
|
||||
<WifiOff size={12} className="text-red" />
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
{healthy ? 'Connected' : isError ? 'Offline' : '…'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopBar({ onToggleCommandPalette }: TopBarProps) {
|
||||
const navigate = useNavigate();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { pathname, urlQuery } = useRouterState({
|
||||
select: (s) => ({
|
||||
pathname: s.location.pathname,
|
||||
urlQuery: ((s.location.search as Record<string, unknown>)?.q as string | undefined) ?? '',
|
||||
}),
|
||||
});
|
||||
const onSearchPage = pathname === '/search';
|
||||
const [q, setQ] = useState(urlQuery);
|
||||
|
||||
useEffect(() => {
|
||||
if (onSearchPage) setQ(urlQuery);
|
||||
}, [urlQuery, onSearchPage]);
|
||||
|
||||
// Debounced URL push on search page
|
||||
useEffect(() => {
|
||||
if (!onSearchPage) return;
|
||||
const id = setTimeout(() => {
|
||||
const next = q.trim();
|
||||
if (next !== urlQuery) {
|
||||
void navigate({ to: '/search', search: { q: next || undefined } as any, replace: true });
|
||||
}
|
||||
}, 250);
|
||||
return () => clearTimeout(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [q, onSearchPage]);
|
||||
|
||||
// Global "/" shortcut: focus search
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
const el = document.activeElement;
|
||||
const tag = el?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || (el as HTMLElement)?.isContentEditable) return;
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (q.trim()) void navigate({ to: '/search', search: { q: q.trim() } as any });
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="glass h-12 border-b border-border flex items-center px-3 gap-3 shrink-0 z-20">
|
||||
{/* Breadcrumbs */}
|
||||
<div className="flex items-center min-w-0 flex-none max-w-[200px]">
|
||||
<Breadcrumbs pathname={pathname} />
|
||||
</div>
|
||||
|
||||
{/* Universal search */}
|
||||
<form onSubmit={handleSubmit} className="flex-1 max-w-md">
|
||||
<div className="relative group">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted pointer-events-none transition-colors group-focus-within:text-accent" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search…"
|
||||
className="w-full bg-surface0/70 border border-border rounded-md pl-8 pr-8 py-1.5 text-xs text-text placeholder:text-muted outline-none focus:border-accent focus:bg-surface0 transition-all"
|
||||
/>
|
||||
{q ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQ('')}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted hover:text-text hover:bg-surface1 transition-colors"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
) : (
|
||||
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 hidden sm:flex items-center rounded border border-border bg-bg2 px-1 py-0.5 text-[10px] font-medium text-muted pointer-events-none">
|
||||
/
|
||||
</kbd>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Right section */}
|
||||
<div className="flex items-center gap-2 flex-none">
|
||||
{/* Connection status */}
|
||||
<ConnectionStatus />
|
||||
|
||||
{/* Command palette toggle */}
|
||||
<button
|
||||
onClick={onToggleCommandPalette}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border bg-surface0 px-2 py-1 text-[11px] font-medium text-muted hover:text-text hover:bg-surface1 transition-colors"
|
||||
title="Command palette (Ctrl+K)"
|
||||
>
|
||||
<Command size={12} />
|
||||
<span className="hidden sm:inline">Commands</span>
|
||||
<kbd className="rounded border border-border bg-bg2 px-1 text-[10px] text-muted">
|
||||
Ctrl+K
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Play, Pause, ThumbsDown, Disc3, Sparkles } from 'lucide-react';
|
||||
import { Link, useRouter } from '@tanstack/react-router';
|
||||
import type { Track } from '../types';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useDislikeTrack } from '../hooks/useDislikeTrack';
|
||||
import { vibeService } from '../services/vibeService';
|
||||
import { Artwork } from './Artwork';
|
||||
import { ArtistLinks } from './ArtistLinks';
|
||||
|
||||
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')}`;
|
||||
}
|
||||
|
||||
type TrackRowVariant = 'default' | 'compact';
|
||||
|
||||
interface TrackRowProps {
|
||||
track: Track;
|
||||
queue: Track[];
|
||||
index: number;
|
||||
showActions?: boolean;
|
||||
/** compact — smaller artwork, no duration, for queue panels / VibeTimeline */
|
||||
variant?: TrackRowVariant;
|
||||
/** Show a "Vibe by track" button that starts a vibe session seeded from this track. */
|
||||
showVibe?: boolean;
|
||||
}
|
||||
|
||||
export function TrackRow({ track, queue, index, showActions = true, variant = 'default', showVibe = false }: TrackRowProps) {
|
||||
const { setQueue, playTrack, play, pause, currentTrack, isPlaying } = usePlaybackStore();
|
||||
const dislikeTrack = useDislikeTrack();
|
||||
const router = useRouter();
|
||||
const isCurrent = currentTrack?.id === track.id;
|
||||
const compact = variant === 'compact';
|
||||
|
||||
const handlePlay = () => {
|
||||
if (isCurrent) { isPlaying ? pause() : play(); return; }
|
||||
setQueue(queue.slice(index));
|
||||
playTrack(track);
|
||||
};
|
||||
|
||||
const handleDislike = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
dislikeTrack(track.id);
|
||||
};
|
||||
|
||||
const handleVibe = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// Start a vibe session then navigate to the vibe page.
|
||||
vibeService.start(track.id).then(() => {
|
||||
router.navigate({ to: '/vibe' });
|
||||
}).catch(() => {
|
||||
// Session failed — still navigate so the user can try manually.
|
||||
router.navigate({ to: '/vibe' });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handlePlay}
|
||||
className={`group flex w-full cursor-pointer items-center gap-3 rounded-lg border transition-colors ${
|
||||
compact ? 'p-2' : 'p-2.5'
|
||||
} ${
|
||||
isCurrent
|
||||
? 'border-accent/60 bg-accent/10'
|
||||
: 'border-border/70 bg-surface0/50 hover:border-accent/30 hover:bg-surface1'
|
||||
}`}
|
||||
>
|
||||
{/* Artwork + play overlay */}
|
||||
<div className={`relative flex flex-none items-center justify-center rounded overflow-hidden ${
|
||||
compact ? 'h-9 w-9' : 'h-10 w-10'
|
||||
}`}>
|
||||
<Artwork seed={`${track.title} ${track.artist}`} src={track.artwork_id} className="absolute inset-0 w-full h-full" />
|
||||
{isCurrent && isPlaying ? (
|
||||
<Pause size={compact ? 14 : 18} className="absolute z-20 text-text opacity-100" />
|
||||
) : (
|
||||
<Play size={compact ? 14 : 18} className="absolute z-20 text-text opacity-0 group-hover:opacity-100" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title + artist */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`truncate font-medium ${isCurrent ? 'text-accent' : 'text-text'} ${compact ? 'text-sm' : 'text-sm'}`}>
|
||||
{track.title || 'Untitled'}
|
||||
</div>
|
||||
<ArtistLinks
|
||||
artists={track.artists}
|
||||
fallback={track.artist}
|
||||
stopPropagation
|
||||
className={`truncate block text-muted ${compact ? 'text-xs' : 'text-xs'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions (vibe → album link → dislike) */}
|
||||
{showActions && !compact && (
|
||||
<div className="flex flex-none items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{showVibe && (
|
||||
<button onClick={handleVibe} title="Vibe by track" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-accent">
|
||||
<Sparkles size={16} />
|
||||
</button>
|
||||
)}
|
||||
{track.album_id && (
|
||||
<Link
|
||||
to="/albums/$albumId"
|
||||
params={{ albumId: track.album_id }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Go to album"
|
||||
className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-text"
|
||||
>
|
||||
<Disc3 size={16} />
|
||||
</Link>
|
||||
)}
|
||||
<button onClick={handleDislike} title="Dislike" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-red-400">
|
||||
<ThumbsDown size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Duration (hidden in compact) */}
|
||||
{!compact && (
|
||||
<div className="flex-none text-xs tabular-nums text-muted">{formatDuration(track.duration)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Radio } from 'lucide-react';
|
||||
import { Badge } from './ethos/Badge';
|
||||
import { TrackRow } from './TrackRow';
|
||||
import type { Track } from '../types';
|
||||
|
||||
interface VibeTimelineProps {
|
||||
currentTrack: Track | null;
|
||||
upcoming: Track[];
|
||||
}
|
||||
|
||||
export function VibeTimeline({ currentTrack, upcoming }: VibeTimelineProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-text">
|
||||
<Radio size={18} className="text-accent" />
|
||||
<h2 className="text-lg font-semibold">Incoming recommendations</h2>
|
||||
<span className="text-xs text-muted">({upcoming.length} buffered)</span>
|
||||
</div>
|
||||
|
||||
{currentTrack && (
|
||||
<div className="relative">
|
||||
<TrackRow
|
||||
track={currentTrack}
|
||||
queue={[currentTrack]}
|
||||
index={0}
|
||||
showActions={false}
|
||||
variant="compact"
|
||||
/>
|
||||
<Badge color="accent" className="absolute right-2 top-1/2 -translate-y-1/2">Now playing</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{upcoming.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-surface0 p-4 text-sm text-muted">
|
||||
No upcoming tracks buffered yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{upcoming.map((track, i) => (
|
||||
<TrackRow
|
||||
key={`${track.id}-${i}`}
|
||||
track={track}
|
||||
queue={upcoming.slice(i)}
|
||||
index={0}
|
||||
showActions={false}
|
||||
variant="compact"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type BadgeColor = 'green' | 'amber' | 'red' | 'purple' | 'cyan' | 'orange' | 'neutral' | 'accent';
|
||||
|
||||
interface BadgeProps {
|
||||
color?: BadgeColor;
|
||||
dot?: boolean;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COLOR_CLASSES: Record<BadgeColor, string> = {
|
||||
green: 'bg-green/10 text-green border-green/25',
|
||||
amber: 'bg-amber/10 text-amber border-amber/25',
|
||||
red: 'bg-red/10 text-red border-red/25',
|
||||
purple: 'bg-purple/10 text-purple border-purple/25',
|
||||
cyan: 'bg-cyan/10 text-cyan border-cyan/25',
|
||||
orange: 'bg-orange/10 text-orange border-orange/25',
|
||||
neutral: 'bg-surface1 text-secondary border-border',
|
||||
accent: 'bg-accent/10 text-accent border-accent/25',
|
||||
};
|
||||
|
||||
const DOT_COLORS: Record<BadgeColor, string> = {
|
||||
green: 'bg-green',
|
||||
amber: 'bg-amber',
|
||||
red: 'bg-red',
|
||||
purple: 'bg-purple',
|
||||
cyan: 'bg-cyan',
|
||||
orange: 'bg-orange',
|
||||
neutral: 'bg-muted',
|
||||
accent: 'bg-accent',
|
||||
};
|
||||
|
||||
/**
|
||||
* Ethos badge — semantic status indicator.
|
||||
* Only uses colors from the Ethos semantic set. Never decorative.
|
||||
*/
|
||||
export function Badge({ color = 'neutral', dot = false, children, className = '' }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium ${COLOR_CLASSES[color]} ${className}`}
|
||||
>
|
||||
{dot && <span className={`w-1.5 h-1.5 rounded-full ${DOT_COLORS[color]}`} />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'link';
|
||||
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
loading?: boolean;
|
||||
icon?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
'bg-accent text-on-accent hover:bg-accent-h border border-accent shadow-sm',
|
||||
secondary:
|
||||
'bg-surface0 text-text hover:bg-surface1 border border-border',
|
||||
ghost:
|
||||
'text-secondary hover:text-text hover:bg-surface0 border border-transparent',
|
||||
danger:
|
||||
'bg-red/10 text-red hover:bg-red/20 border border-red/30',
|
||||
link:
|
||||
'text-accent hover:text-accent-h border border-transparent underline-offset-2 hover:underline p-0',
|
||||
};
|
||||
|
||||
const SIZE_CLASSES: Record<ButtonSize, string> = {
|
||||
sm: 'px-2 py-1 text-xs rounded',
|
||||
md: 'px-3 py-1.5 text-sm rounded-md',
|
||||
lg: 'px-4 py-2 text-sm rounded-md',
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
icon,
|
||||
children,
|
||||
className = '',
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`inline-flex items-center justify-center gap-1.5 font-medium transition-all duration-100 focus-visible:outline-2 focus-visible:outline-accent disabled:opacity-40 disabled:pointer-events-none ${VARIANT_CLASSES[variant]} ${SIZE_CLASSES[size]} ${className}`}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<svg className="animate-spin h-3.5 w-3.5" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
) : icon ? (
|
||||
<span className="flex-none">{icon}</span>
|
||||
) : null}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ethos empty state — icon, message, primary action. No illustrations.
|
||||
* Matches the Ethos spec for zero-state surfaces.
|
||||
*/
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
compact = false,
|
||||
className = '',
|
||||
}: EmptyStateProps) {
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center gap-2 rounded-md border border-dashed border-border py-10 text-center animate-fade-in ${className}`}
|
||||
>
|
||||
{icon && <span className="text-muted">{icon}</span>}
|
||||
<div className="font-medium text-text text-sm">{title}</div>
|
||||
{subtitle && <div className="text-xs text-muted">{subtitle}</div>}
|
||||
{action && (
|
||||
<Button variant="secondary" size="sm" onClick={action.onClick}>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center gap-3 rounded-md border border-dashed border-border py-16 text-center animate-fade-in ${className}`}
|
||||
>
|
||||
{icon && <span className="text-muted">{icon}</span>}
|
||||
<div>
|
||||
<div className="font-semibold text-text">{title}</div>
|
||||
{subtitle && <div className="mt-1 text-sm text-muted">{subtitle}</div>}
|
||||
</div>
|
||||
{action && (
|
||||
<Button variant="secondary" size="md" onClick={action.onClick}>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Ethos skeleton loading states.
|
||||
* Uses shimmer animation (defined in index.css). Never uses spinners
|
||||
* unless waiting on an unknown-duration task.
|
||||
*/
|
||||
|
||||
export function Skeleton({ className = '' }: { className?: string }) {
|
||||
return <div className={`skeleton ${className}`} />;
|
||||
}
|
||||
|
||||
/** Rows matching TrackRow height */
|
||||
export function SkeletonRows({ count = 5 }: { count?: number }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-md px-3 py-2">
|
||||
<Skeleton className="h-8 w-8 flex-none rounded" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3 w-1/3" />
|
||||
<Skeleton className="h-2.5 w-1/4" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-8 flex-none" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Grid matching album/artist card layout */
|
||||
export function SkeletonGrid({ count = 10 }: { count?: number }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="flex flex-col gap-2 rounded-md border border-border bg-surface0 p-2">
|
||||
<Skeleton className="aspect-square rounded" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
<Skeleton className="h-2.5 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user