initial state: muzick music player + recommendation engine
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user