Ideas, experiments, and ongoing thoughts...

What Happened After Giving SpinStack to Some Testers

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

OperationBeforeAfterImprovement
Insights page (≥5k rows)2–3 s≈ 200 ms10–15× faster
Stats queries500–800 ms50–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

  1. Test at scale, even if it’s fake data.
    Generate thousands of records. Stress your system.

  2. Silent failures are worse than crashes.
    Observability > assumptions.

  3. Trust authoritative identifiers.
    Use what the API gives you.

  4. Different data sources need different logic.
    Radio ≠ streaming ≠ local.

  5. Real users find real bugs.
    Their habits uncover what yours never will.

  6. 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.

← Back to Articles