6.9 KiB
6.9 KiB
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
mbidand 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_sourceon tracks:lastfm|musicbrainz) so feedback can be attributed. - Respect rate limits: queue external calls, cache responses in sqlite.
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, artistgenres, artistimage_path:- MusicBrainz lookup by artist+title → recording MBID + tags.
- Last.fm
artist.getInfo/track.getInfofor 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:
- Seed = current track / chosen track / genre.
- Pull similar from Last.fm (
track.getSimilar,artist.getSimilar,tag.getTopTracks), cached. - Score each candidate against the owned library:
- +score if artist/album already owned, genre overlap, similar to favorites, low recent-play (freshness), not disliked.
- 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.
- 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:
// 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_sourceset, hidden from normal library lists (filtered byprobation=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,sourcestaysrecommendationfor analytics but it's now full library, eligible as a future recommendation seed. Logfeedback(action='promoted').
Dislike → delayed delete flow
Exact lifecycle:
- User dislikes a track → insert/[update]
dislikesrow: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. Alsofeedback(action='disliked'). - Reminder: a periodic sweep finds
state='hidden'rows pastwarn_after→ fire notification (ntfy) / surface a toast in UI: "You disliked ''. It will be deleted in 24h — are you sure?" Setwarned_at=now,delete_after=now + 24h,state='warned'. - Final: sweep finds
state='warned'rows pastdelete_afterwhere the dislike still stands → delete file from fs + all db rows (calls coreDELETE /api/track). Setstate='deleted'(or just remove the dislike row since the track row is gone). - Un-dislike at any point before final deletion → remove
dislikesrow, 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)
feedbacktable records promoted/disliked/skipped withrec_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_urlfor reminders