Files
muzick/04-music-recommendation-backend.md
T

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 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.
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:
// 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 ''. 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