A couple of weeks ago, I released SpinStack to a small group of testers, my self-hosted music tracking system for Sonos.
It was that exciting and slightly terrifying moment when the it “works on my machine” project has to survive someone else’s environment.
Their feedback (and my own fresh runs) kicked off a sprint of improvements that taught me a lot about building better software.
Problem 1: Database Performance at Scale
The Issue
I’d been testing SpinStack with maybe a few hundred tracks. Everything felt snappy, the Insights page loaded instantly, queries were fast, no issues.
Then I asked myself: what happens after a few years of listening history say many thousands of tracks?
With mock data, the Insights page load time jumped to 2–3 seconds and stats queries became sluggish.
The database was doing full table scans for every query, which is fine with 200 rows, a bottleneck with 5000+.
The Solution
I added three strategic indexes to the scrobbles table:
CREATE INDEX IF NOT EXISTS idx_scrobbles_timestamp
ON scrobbles (timestamp);
CREATE INDEX IF NOT EXISTS idx_scrobbles_music_service_timestamp
ON scrobbles (music_service, timestamp);
CREATE INDEX IF NOT EXISTS idx_scrobbles_artist_title
ON scrobbles (artist, title);
Why these specific ones:
- timestamp — powers queries like “plays from the last 7 days” or “top tracks this month.”
- music_service + timestamp — fast filtering by service and time window.
- artist + title — enables search, deduplication, and “find all plays of X song.”
The Impact
| Operation | Before | After | Improvement |
|---|---|---|---|
| Insights page (≥5k rows) | 2–3 s | ≈ 200 ms | 10–15× faster |
| Stats queries | 500–800 ms | 50–80 ms | ≈ 10× faster |
| Track search | ≈ 800 ms | ≈ 60 ms | ≈ 13× faster |
The Lesson
Profile before optimizing.
I didn’t guess which indexes to add, I used EXPLAIN QUERY PLAN in SQLite to find where it hurt.
And test at scale.
Problem 2: Silent Background Task Failures
The Issue
After a week or two of uptime, SpinStack would occasionally stop logging tracks.
No crash, no error, no warning, just silence. The app stayed up, but the scrobbler quietly died.
Root Cause
I was using Python’s ThreadPoolExecutor for background tasks.
When a submitted task raised an exception, it silently died without propagating the error.
The executor kept running, but the worker was gone.
The Solution
Wrap tasks with proper exception handling and restart logic.
I also added a health-check endpoint to monitor background activity.
import asyncio
import logging
from typing import Callable
logger = logging.getLogger(__name__)
async def run_background_task_safely(
task_func: Callable,
task_name: str,
restart_on_failure: bool = True,
):
"""Run a background task with exception handling and optional restart."""
while True:
try:
logger.info("Starting background task: %s", task_name)
await task_func()
except asyncio.CancelledError:
logger.info("Background task cancelled: %s", task_name)
break
except Exception as e:
logger.error("Background task %s failed: %s", task_name, e, exc_info=True)
if not restart_on_failure:
break
await asyncio.sleep(30)
logger.info("Restarting background task: %s", task_name)
Simple FastAPI health check:
from fastapi import APIRouter
from datetime import datetime, timedelta
router = APIRouter()
last_successful_poll = datetime.now()
@router.get("/health")
async def health_check():
delta = datetime.now() - last_successful_poll
if delta > timedelta(minutes=5):
return {
"status": "unhealthy",
"message": "Monitoring task hasn't polled in 5+ minutes",
"last_poll": last_successful_poll.isoformat(),
}
return {"status": "healthy", "last_poll": last_successful_poll.isoformat()}
The Impact
- Background tasks now restart automatically.
- Health check endpoint exposes failures.
- Logs show why a task failed.
The Lesson
Silent failures are worse than crashes.
Crashes are loud and fixable.
Problem 3: Music Service Detection Was Wrong
The Issue
I was detecting streaming services by parsing URI patterns:
def detect_service(uri: str) -> str:
if "x-sonos-spotify" in uri: return "spotify"
if "x-file-cifs" in uri: return "local_library"
if "x-sonos-http" in uri: return "http_stream"
return "unknown"
This worked sometimes — but not well:
- Apple Music → detected as
http_stream - TuneIn Radio → not detected at all
The Real Solution: Service IDs (SID)
Every Sonos service includes a unique sid parameter in its URI.
Example: sid=204 = Apple Music, sid=254 = Spotify etc… etc…
from urllib.parse import urlparse, parse_qs
SERVICE_ID_MAP = {
"2": "deezer",
"160": "amazon_music",
"201": "pandora",
"204": "apple_music",
"254": "spotify",
"306": "tunein",
"313": "sonos_radio",
}
def parse_music_service_from_uri(uri: str) -> str:
parsed = urlparse(uri)
sid = parse_qs(parsed.query).get("sid", [None])[0]
if sid and sid in SERVICE_ID_MAP:
return SERVICE_ID_MAP[sid]
if uri.startswith("x-file-cifs"): return "local_library"
if uri.startswith("x-rincon-stream"): return "line_in"
return "unknown"
The Impact
- Service detection accuracy is now pretty solid.
The Lesson
Trust authoritative identifiers over heuristics.
If the API or URI gives you some structured data, use it.
Problem 4: Radio Streams Never Logged
The Issue
Position-based logic broke for radio:
if current_position >= (duration * 0.5):
log_to_database(track)
Radio tracks always report duration=0 and position=0.
The threshold was never reached, so radio never scrobbled.
The Solution
Use track-change-based scrobbling for radio, and position-based for on-demand.
def should_scrobble_track(
current_track: dict,
previous_track: dict,
current_pos: int,
duration: int,
music_service: str,
) -> bool:
radio_services = {"tunein", "sonos_radio", "pandora", "radio_stream", "http_stream"}
if music_service in radio_services:
changed = (
current_track.get("title") != previous_track.get("title")
or current_track.get("artist") != previous_track.get("artist")
)
return bool(changed)
if duration > 0:
return current_pos >= (duration * 0.5)
return False
The Impact
- Metadata updates correctly
- Deduplication prevents re-logging
The Lesson
Different sources need different logic.
Radio ≠ on-demand.
Improvements That Worked
Hot-Reload for Plugin Configs
Before: update config → restart app → wait → hope.
Now: update → reload instantly, no downtime.
class DiscogsPlugin(SpinStackPlugin):
async def reload_config(self):
config = self.config_manager.get_plugin_config(self.id)
user_token = config.get("user_token")
self.http_client = httpx.AsyncClient(
timeout=30.0,
headers={"Authorization": f"Discogs token={user_token}"}
)
self.logger.info("Discogs plugin config reloaded")
Impact: zero downtime and faster iteration.
ISRC Tracking: Infrastructure for the Future
ISRCs uniquely identify recordings.
I added ISRC fetching to unify play metadata across services.
async def fetch_isrc(artist: str, title: str) -> str | None:
isrc = await fetch_isrc_from_deezer(artist, title)
if isrc:
return isrc
return await fetch_isrc_from_musicbrainz(artist, title)
Enables:
- Universal share links
- Better analytics
Listening Insights: Pivot from Failure
The original “smart queue builder” couldn’t work technically.
I scrapped it and replaced it with Listening Insights — visual listening habits, discovery trends, and time-based views.
Lesson: kill features that don’t work. Pivot fast.
Key Takeaways
-
Test at scale, even if it’s fake data.
Generate thousands of records. Stress your system. -
Silent failures are worse than crashes.
Observability > assumptions. -
Trust authoritative identifiers.
Use what the API gives you. -
Different data sources need different logic.
Radio ≠ streaming ≠ local. -
Real users find real bugs.
Their habits uncover what yours never will. -
Delete what doesn’t work.
Simplicity scales better than hacks.
What’s Next
Short-term
- ISRC integration into the metadata(base)
- New insight modes (genre diversity, discovery score)
Mid-term
- Last.fm import
Long-term
- Line-In Audio Fingerprinting for Vinyl scrobbling
The Value of Side Projects
Side projects are great learning vehicles precisely because they’re allowed to fail.
My output at work benefits from every mistake I make in SpinStack.
Better to break things at home, and bring the lessons learned to work.