Spotify Moodroom: Library-Mined Auto-Queue
A mood-based auto-queue I built for myself, to fix the moment when a Spotify playlist runs dry and the recommender takes over. Six rooms (Late Night, Throwback Pop, House, Rage, Throwback Rap, After Hours), each backed by a three-tier pool (CONFIRMED · SAFE_SIDE · DISCOVERY) mined from my own 246 liked songs by an LLM scan. Click a room and a weighted sampler draws ten tracks (5 confirmed + 4 safe + 1 discovery), every click is a fresh batch. A background Python daemon polls Spotify every six seconds; when the track_id changes, that's the signal a song just ended and the queue gets one fresh top-up. No interval triggers, no scheduled jobs, no autoplay. The Spotify queue API is unreliable (returns stale snapshots even after tracks play through) so the system ignores it as a trigger and runs purely on track-change events. Skip a track in under thirty seconds and it writes the demotion to a state.json overlay: CONFIRMED → SAFE_SIDE on the first strike, SAFE → removed on the second. A discovery that survives a full play auto-promotes to SAFE_SIDE. The pool refines itself over weeks based on how I actually listen, not on a survey. Late Night was calibrated through five live feedback rounds before the other rooms were built, vibe locked, anchors named (Franchise, Trophies, My Wrist, Right Now, Spaceship), audit pass cut thirteen off-room misfits. The runtime is a Flask app on my laptop talking to the Spotify Web API; it auto-queues only while I have it open. Source is open, anyone with a Premium account can fork it and re-mine it against their own library, and the patterns inside (skip-demote, library mining, event-driven top-up) are things Spotify could implement directly.
Pipeline Architecture
OAuth pulls my liked songs into SQLite · an LLM scans every track and assigns it to one of six rooms I defined · clicking a room runs the sampler (5 CONFIRMED + 4 SAFE + 1 DISCOVERY, shuffled, no recent repeats) · Spotify plays the first track and the rest are added to my queue · a daemon thread polls Spotify every six seconds, detects track changes, and adds one fresh track on every completion · skips in under thirty seconds write demotions to a state.json overlay; full plays of a discovery auto-promote it to the safe pool. All running locally on my laptop while the Flask app is open.
What’s actually happening at each stage
Each stage is explained twice, first for the finance reader, then for the engineer.
1. LLM-Mined Rooms from My Real Library
Finance lens
Spotify's algorithm pushes you toward the centroid of people who listen like you, useful for discovery, terrible for vibe. The whole point of Moodroom is that the pools are mined directly from my own liked songs, not from collaborative filtering. I define the rooms by hand (Late Night = OVO atmospheric / hook-driven, Rage = modern hard trap, After Hours = cinematic electronic, etc.) and an LLM scans every track in my library and places each one in the room it actually fits. Forty-five of the sixty-four Late Night tracks were already in my library, the system just surfaces them in the right context. Spotify wouldn't bias a vibe playlist toward stuff I already own.
Engineering lens
OAuth via Spotipy with user-library-read + user-modify-playback-state scopes, library paginated into a SQLite snapshot (tracks · artists · track_artists). An LLM call (Claude via OpenRouter) takes my 246-track library and six room definitions and outputs a per-room CONFIRMED list, each track tagged with the room it fits. The same model is used to seed SAFE_SIDE (adjacent picks not in my library) and DISCOVERY_POOL (fresh outside picks). All three tiers live in moods.py as static seed data; runtime mutations land in a separate state.json overlay so moods.py stays clean. Anyone who forks the repo points it at their own account and the same pipeline produces their own rooms.
2. Three-Tier Pool + Weighted Sampler
Finance lens
Every click draws ten tracks from a three-tier pool: five from CONFIRMED (validated hits I've already liked), four from SAFE_SIDE (curated unverified picks in the same room), and one from DISCOVERY (a fresh outside pick the system is auditioning). The mix is random within each tier but the ratio is fixed, so each batch leans toward proven tracks while still surfacing something new. A rolling fifteen-pick dedup window prevents the same track from being chosen back-to-back, even when the RNG would have.
Engineering lens
sample_batch(mood, k=10, n_confirmed=5, n_safe=4, n_discovery=1, exclude=set) does the work. Pools are resolved through mood_state.effective_pools() which applies the state.json overlay: subtract removed tracks, move demoted from CONFIRMED → SAFE_SIDE, append promoted discoveries to SAFE_SIDE. The exclude set is a 15-entry deque of recent (name, artist) tuples maintained by _remember_queued(), filtered out of each pool before random.sample() picks. Backfill logic handles short pools (e.g. a fresh room with only 7 CONFIRMED tracks) by topping up from the other tiers without breaking the ratio.
3. Event-Driven Top-Up: Track Changes, Not Intervals
Finance lens
The original design topped up the queue every sixty seconds. I changed it: top-ups should only fire when a song completed, not on a schedule. The fix turned out to be a system improvement too, Spotify's queue endpoint returns stale snapshots (it kept reporting nine upcoming tracks even after they'd played), so any threshold-based check was broken anyway. Rewrote the loop around track-change events: every six seconds the daemon polls current_playback, and when the track_id differs from the last poll, that's the signal one song just finished. Add one fresh track to the queue. As long as my Flask app is open, the queue stays continuously alive, Spotify autoplay never gets a turn.
Engineering lens
Background daemon thread spawned on first /play call (and auto-resumed on import if state.json has an active_mood). Loop: poll current_playback every 6s, compare playback["item"]["id"] to _last_poll["track_id"]. On change, classify the previous track, if prev_progress_ms < 30000, it was a fast skip → record_skip() writes the demotion to state.json. If prev_progress_ms ≥ duration_ms - 8s, it was a natural completion → if the track was a discovery, promote_discovery() moves it to SAFE_SIDE. Then call _topup_queue(n=1) which samples one fresh track and calls sp.add_to_queue() with explicit device_id. Initial 10-track batch uses start_playback(track 1) + add_to_queue(tracks 2-10), start_playback with a multi-URI list creates a transient context that dies at the last URI (the bug that originally caused playback to stop). One-URI-start + manual queue avoids that.
4. Skip-Demote Feedback Loop + Discovery Promotion
Finance lens
A playlist on shuffle has no idea I skipped a track. Moodroom watches every skip and rewrites the pool. Skip in <30s → CONFIRMED moves to SAFE_SIDE (one strike). Skip again → removed entirely (two strikes). Discoveries are stricter: one strike → removed forever. A discovery that survives a full play auto-promotes to SAFE_SIDE, so the pool grows itself over weeks without me touching it. The system learns from how I actually listen, not from a survey button. Late Night was calibrated through five live feedback rounds before the other rooms existed (vibe locked at OVO atmospheric / hook-driven, anti-targets identified: rage too far, sleepy-without-hook, pure R&B); a formal audit pass after the build cut thirteen off-room misfits and reversed two flags after closer listen.
Engineering lens
state.json is the runtime source of truth: {skip_counts: {"mood::name::artist": int}, demoted: {mood: [[name, artist], ...]}, removed: {...}, promoted_safe: {...}, active_mood, events: ring buffer}. mood_state.record_skip(mood, name, artist, bucket, at_ms, dur_ms) increments the counter and writes the appropriate mutation. The bucket is tracked in _queued (track_id → {name, artist, mood, bucket: 'confirmed' | 'safe' | 'discovery'}) so the daemon knows which tier a finished track came from. Events get appended to state["events"] (capped at 20) and surfaced in the UI as a prominent skip notice that includes the elapsed time of the skip (e.g. "first skip · at 0:12 of 3:42 · CONFIRMED → DEMOTED to SAFE_SIDE"). The audit pass was a manual reasoning step, flagged 20 candidates, defended 7 borderlines, removed 13 with written reasoning per track (Memphis trap energy mismatch, Hurry Up Tomorrow disco texture, 10-min Frank Ocean structural drift, etc.).
5. Cinematic Per-Room UI · Local-First Today: Cloud Planned
Finance lens
Each room has its own visual identity. Late Night is indigo and violet, Throwback Pop is hot pink and cyan, House is amber and sunset, Rage is red and crimson, Throwback Rap is gold and forest green, After Hours is teal and deep blue. Animated aurora gradients drift in the background, three blurred blobs per room on staggered loops (28s, 34s, 40s) so they never sync up. Album art fades in behind the now-playing manifest, blurred and color-tinted to the active room. Tracks render as a numbered editorial manifest (Fraunces serif, JetBrains Mono telemetry), each row carrying a CONF / SAFE / DISC badge so I can see which pool a track came from before it ever plays. Build 01, what exists today, runs locally on my laptop. Build 02 (planned) is the always-on Fly.io deploy: cloud server holds the pools and runs the daemon, my phone's Spotify becomes the speaker, click a room from any browser. The cinematic UI is shipped; the cloud-brain version is on the roadmap.
Engineering lens
Flask backend on port 7777 (avoiding macOS AirPlay's 5050 hijack). Templates served via Jinja2, Tailwind CDN, vanilla JS, no bundler. CSS-driven aurora: three .blob divs with mix-blend-mode: screen and radial-gradient backgrounds, animated via @keyframes drift-a/b/c at different timings. Per-room colors injected at click time via CSS custom properties (--accent, --bg-from, --bg-to, --blob-a/b/c) so the whole stage crossfades into the new palette over 1.6s. Album art polled via /now endpoint every 5s; crossfade by swapping img.src on a layered .art-bg element. The now-playing card displays a live bucket badge, an amber 0:30 tick on the progress bar (the skip-vs-natural threshold), and a "next skip would →" preview computed against current bucket + skip count. Build 02 plan: single Docker container (gunicorn -w 1 --threads 4 with the daemon running inside the worker), persistent volume mounted at /data for state.json + library.db + .cache, env-driven DATA_DIR / PORT / HOST / SPOTIPY_REDIRECT_URI. Web OAuth via /login + /callback already works; the Fly.io deploy itself is the next item on the roadmap, not yet shipped.
Methodology notes
Three-tier pool architecture (CONFIRMED · SAFE_SIDE · DISCOVERY) with deterministic 5+4+1 sample ratio per click. CONFIRMED is mined from my own liked songs by an LLM scan; SAFE_SIDE is LLM-curated adjacent picks; DISCOVERY is fresh outside material that auto-promotes on full play. moods.py stays clean as seed data, all runtime mutations land in state.json overlay applied at sample time via mood_state.effective_pools().
Event-driven auto-queue: background daemon polls current_playback every 6s, fires add_to_queue exactly once per detected track-change. The Spotify queue endpoint is unreliable (returns stale snapshots) and is ignored as a trigger, used only to display upcoming tracks in the UI. Initial batch uses start_playback(track 1 only) + add_to_queue(tracks 2-10) to avoid the transient-context bug where playback dies at the last URI.
Skip-demote learning via 30-second threshold. State machine: CONFIRMED → SAFE_SIDE on first sub-30s skip, SAFE_SIDE → removed on second. Discoveries get one strike → removed. Discoveries surviving a full play (prev_progress ≥ duration - 8s) auto-promote to SAFE_SIDE. All mutations append to a ring buffer of events surfaced in the UI as a prominent skip notice with elapsed-time and bucket transition (e.g. "first skip · at 0:12 of 3:42 · CONFIRMED → DEMOTED").
Rolling 15-pick dedup deque prevents back-to-back repeats. Every successful queue-add appends the (name, artist) tuple; sample_batch filters that set out of each tier before random.sample(). Verified empirically, 30 sequential 1-pick samples produced zero immediate repeats.
Cinematic UI with per-room palette system: 6 rooms × 3 accent colors + bg-from + bg-to + 3 aurora blob colors, all CSS custom properties crossfading over 1.6s on room switch. Three radial-gradient blobs with mix-blend-mode: screen drift on staggered keyframe loops (28s / 34s / 40s) for organic-feeling motion. The now-playing card carries a live bucket badge, an amber 0:30 tick on the progress bar (the skip-vs-natural threshold), and a "next skip would →" preview computed from the current bucket + skip count.
Local-first runtime today (Build 01): Flask app on my laptop, talking directly to the Spotify Web API. Auto-queues only while I have the app open. Cloud-brain / phone-speaker deployment is Build 02 on the roadmap, single Fly.io Docker container with persistent volume for state.json + library.db, web OAuth via /login + /callback, daemon surviving across browser sessions so my phone's Spotify can be the speaker from anywhere. Source is open: anyone with a Premium account can fork the repo and re-mine it against their own library, and Spotify itself could implement these patterns (skip-demote, library mining, event-driven top-up) directly.