initial state: muzick music player + recommendation engine

This commit is contained in:
kami
2026-07-14 01:35:52 +04:00
commit 737bf19fd1
196 changed files with 32431 additions and 0 deletions
+10
View File
@@ -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 *)"
]
}
}
+12
View File
@@ -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
+23
View File
@@ -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
View File
@@ -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
+163
View File
@@ -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.
+129
View File
@@ -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
+80
View File
@@ -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>`.
+28
View File
@@ -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.
+44
View File
@@ -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
```
+96
View File
@@ -0,0 +1,96 @@
# Session — 07 July 2026
## Scaffolded: v2 Recommendation Engine — code complete, not yet deployed (Systems AE + 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
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.git
.env
+8
View File
@@ -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"]
+3206
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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"
}
}
+52
View File
@@ -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();
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# Initialize the database schema
psql "$DATABASE_URL" -f src/db/schema.sql
echo "Database initialized successfully."
+185
View File
@@ -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 };
}
+490
View File
@@ -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);
+1
View File
@@ -0,0 +1 @@
console.log('Backend starting...');
+194
View File
@@ -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,
};
});
}
+94
View File
@@ -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 });
});
}
+152
View File
@@ -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 });
});
}
+52
View File
@@ -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' });
});
}
+49
View File
@@ -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' });
}
});
}
+185
View File
@@ -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' });
});
}
+34
View File
@@ -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' });
});
}
+21
View File
@@ -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' });
}
});
}
+53
View File
@@ -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 };
}
);
}
+140
View File
@@ -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);
});
}
+143
View File
@@ -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 });
});
}
+76
View File
@@ -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' });
});
}
+21
View File
@@ -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();
+182
View File
@@ -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
+297
View File
@@ -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));
}
}
+500
View File
@@ -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,
];
+179
View File
@@ -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;
}
}
+133
View File
@@ -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();
}
}
+94
View File
@@ -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');
});
});
});
+27
View File
@@ -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;
+21
View File
@@ -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"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
},
});
+68
View File
@@ -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
+52
View File
@@ -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.
+53
View File
@@ -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.
+45
View File
@@ -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.
+53
View File
@@ -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;`
+57
View File
@@ -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.
+63
View File
@@ -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
+797
View File
@@ -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 18 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 17), 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 127134):
```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 712748):
```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 813821):
```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 AE + Phase 4)
```
Replace with:
```
## Scaffolded: v2 Recommendation Engine — code complete, not yet deployed (Systems AE + 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 8688)
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 8992)
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
+139
View File
@@ -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?
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.git
.env
+12
View File
@@ -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;"]
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Muzick</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+18
View File
@@ -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;
}
}
+3208
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -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"
}
}
+6
View File
@@ -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.
+74
View File
@@ -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>
);
}
+67
View File
@@ -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>
);
}
+66
View File
@@ -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>
);
}
+291
View File
@@ -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 />;
};
+42
View File
@@ -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>
);
}
+222
View File
@@ -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>
</>
);
}
+5
View File
@@ -0,0 +1,5 @@
/**
* Re-export from the Ethos component library.
* All existing imports continue to work.
*/
export { EmptyState } from './ethos/EmptyState';
+169
View File
@@ -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>
);
}
+29
View File
@@ -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>
);
}
+72
View File
@@ -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>
);
}
+32
View File
@@ -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>
);
}
+105
View File
@@ -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>
);
}
+114
View File
@@ -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>
);
}
+31
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+42
View File
@@ -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>
);
}
+36
View File
@@ -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>
);
}
+143
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+72
View File
@@ -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>
);
}
+75
View File
@@ -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>
);
}
+194
View File
@@ -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>
);
}
+125
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}
+47
View File
@@ -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>
);
}
+60
View File
@@ -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