Spinslip: the failure-mode table behind a now-playing card
The card on my /now page reads Apple Music plays off my own database. The interesting part of the app feeding it is a five-row table deciding which server responses are allowed to throw listening history away.
A scrobbler’s only real job is not to lose anything. Everything else, the screen, the artwork, the card on the website, is decoration on a queue that either holds its contents or doesn’t, so the part of Spinslip I actually want to write about is a five-row table deciding which HTTP responses are allowed to throw away my listening history.
Some background first. Spinslip is a small iOS app that has been running on my phone since July 26th. It reads Apple Music’s recently played list, works out what’s new, and posts it to a Supabase database I own, and the recently played card on my /now page reads that database and nothing else. Every scrobbler routes through Last.fm, and I might be a little biased but Apple Music straight to your own webpage seemed like the obvious version nobody sells. Claude Code wrote most of the Swift and the edge function, I steered, and for the first week after it went live my job was reading the sync log off the phone every day and deciding whether the numbers were right.
The name is the analogy. A spin slip is the little paper record of what you’ve been playing, and the app is basically a tray of slips waiting to be carried to a counter, so I think the best way to explain the design is to walk one slip through it.
So Apple gives you an ordered list, newest first, and nothing else, not even timestamps. The app asks for 30 entries, remembers the id of whatever sat at the top last time, and on the next poll everything above that old top is new. On the very first run it just writes down the top and emits nothing, otherwise you’d publish 30 historical tracks all stamped with today’s date. If the old top has fallen off the list entirely we missed more than a window, so it takes all 30 and accepts that some plays are gone. That diff is one pure function, no I/O, no clock, it doesn’t even import MusicKit, which is why 11 unit tests can cover it on a simulator that has never had an Apple Music session. It also means the timestamps are a bit of a lie, seen_at is when the app first noticed the track, so plays discovered in one poll land a second apart rather than spread across the hour I actually listened. The card lags by design and I’m pretty fine with that.
Now the order of operations, which is the part I care about most. A new play is written to the tray on disk before the bookmark moves. Say the phone dies between those two writes… on the next sync the bookmark hasn’t moved, the same plays get detected again and go into the tray a second time, each with a freshly minted uuid the server has no way to dedupe, and that’s a duplicate. Flip the two writes and a crash in the same gap loses the play instead, the bookmark moved past it and nothing anywhere remembers it existed. I figure a duplicate is the right failure to choose, and I’d rather say that plainly than call it an accident: the worst case of that ordering is a second slip for the same song, never a missing one. (If the tray itself can’t be written to, the whole sync fails and the bookmark stays put. The comment in the code says “an event that isn’t on disk is an event that will be lost” and that is really the whole reasoning.)
It has happened, too. On July 27th a background sync got cancelled 0.4 seconds before a foreground one started, and “Dog Dribble” showed up twice, 2 ms apart. Twice in one day actually, which is more often than “rare” implied when we wrote the limitation down. The website now collapses two rows for the same track stamped under a second apart, which works because the detector steps the plays inside one batch a full second apart, so two rows for one track closer than that are the same play seen twice.
Then the tray gets carried to the counter. The flush sends slips oldest first, 100 at a time, and this is the table, which is really the whole argument of this post:
| the server says | the batch | the flush |
|---|---|---|
| 200 | removed from the tray | keeps going |
| 401 | kept | stops, retries next sync |
| 429, or any 5xx | kept | stops |
| nothing at all (no network, a 30 second timeout, iOS cancelled the task) | kept | stops |
| any other 4xx (400, 403, 404…) | thrown away, error logged | keeps going |
The last row is the one that looks wrong at first. A 400 means the payload is bad, and a bad payload will not get better by being sent again, so retrying it forever would wedge the queue behind one malformed batch and nothing after it would ever go up. That row has fired for real: the first upload of library play counts came back HTTP 400 {"error":"song_stats[86].artist must not be empty"}, one song with a blank artist, and the server rejects a whole batch on any bad item, so 187 counts landed and 100 didn’t, 99 of them perfectly good. The fix was a sanitizer at the door that refuses an item with an empty required string, one unusable row beats the 99 good ones a 400 takes with it. Those counts were recoverable anyway because they’re Apple’s absolute totals and Apple still had them. A dropped play has no backup like that.
In my eyes the 401 row is the whole design. A 401 also says “I won’t take this”, but the reason heals: the ingest token is wrong, or I’m halfway through rotating it, and the moment a human fixes settings the same batch would be accepted. If 401 sat in the drop row, a wrong token would destroy the backlog one batch per flush. There’s a test that stages exactly that with a 250-play backlog, and with 401 classified as a drop it’s all gone in three round-trips. Instead the flush stops on the first 401, everything stays on disk (up to 500 plays, which for me is weeks of listening), the log says error: HTTP 401 on every sync until I fix the token, and then it drains. That one client property is what makes rotating the token lossless, update either side first and the app just sulks in between. 429 and the 5xx family get the same treatment, the server is the problem and the server will come back, and on July 27th it did: one transient 500 from the edge function, backed off, uploaded on the next sync, nobody did anything.
One more number has to line up for any of this to hold. The 100 in “100 at a time” is pinned to the server’s own limit, which checks > 100 so exactly 100 passes. Raise the client’s number or lower the server’s and every batch is a 400, which is the drop row, so a one-character change would throw away whatever was queued, with a line in the log and nothing else. There is a test whose job is to assert that the client number is still 100.
Two things went wrong that the table doesn’t cover. An actor in Swift is not a lock across an await, so two syncs 7 ms apart (a Shortcuts automation firing while I opened the app) both read the tray before either had removed from it and posted the same batch twice. The server answered 2 dup, harmless because the uuids dedupe, but removal is by count, not identity, and a test staging the same interleaving with one refused batch, guard removed, loses 100 of 200 plays and logs nothing at all. Same shape one step up, remember the bookmark, two syncs diffing the same feed against a bookmark neither had moved yet, 11 duplicate rows out of 96 on July 29th. Both are guarded now with an in-flight flag set before the first await, and the detect one was confirmed on the phone under a 21 ms race with zero duplicates afterwards, the flush one only has the test. Basically the table was right and the thing around the table wasn’t.
Now the part about cadence. BGAppRefreshTask is opportunistic, the app asks iOS for a refresh every 30 minutes and iOS routinely waits hours, or never bothers, for an app you rarely open. What makes the cadence real is a Shortcuts automation that runs the app’s Sync Listening intent every time Music goes to the background, plus a daily one at nine at night as a catch-up. The setup notes originally said hourly, then it turned out iOS Time of Day automations don’t repeat hourly, and the app-close trigger was better anyway because it fires exactly when a listening session ends. If I were tidying this up I’d say background refresh handles it, and it doesn’t, the sync log exists mostly so I can see which trigger is actually carrying the load. The same log is how I know a real outage cost nothing: on July 26th MusicKit failed every request for about an hour and forty five minutes, and when it came back the next sync found all 7 missed plays still sitting above the bookmark, stamped with the recovery moment.
So the conclusion, sort of: a scrobbler is a queue with a policy, and the policy is five rows. Still, the duplicate rows from the crash case are only masked on the read side, the app still writes them, and I keep thinking the detection could mint a stable id from the head position instead of a random uuid, so the server could dedupe that case too… I haven’t tried it, and I’m not sure the position is stable enough to hash. It runs on my phone, feeds one card, and nothing about it has shipped for anyone else yet.
Anyways, this ran a lot longer than five rows deserve. The thing I still want to know is what the cadence looks like with the automation switched off, just background refresh on its own. The experiment is cheap, turn it off for a few days and read the log, and I haven’t run it because I like my plays showing up.
The shorter version is on the project page, and the /now card is what all of this feeds.