My Discover tab was empty because I asked Apple Music the wrong question
This app was later renamed Deep Cut Atlas. It’s called “Discoverer” throughout below, because that’s what it was called on the day this happened.
The Discover tab in my app is the whole point of it: take the artists already in your Apple Music library and surface their releases you don’t own yet. Deep cuts. The tab shipped to my own phone showing “You’re all caught up” with an empty list, and the Xcode console was spitting errors every time it loaded. Core feature, dead.
The symptom lied to me
An empty “all caught up” state and a console full of errors feel like two different bugs. My first instinct was rate limiting: the tab walked my entire library, fetched each artist’s catalog, and diffed. Fire that many requests at MusicKit and it pushes back. That story explained the errors but not the empty feed, and it had me reaching for throttling before I understood anything.
The honest move was to stop guessing and read what the device actually did. MusicKit returns nothing in the simulator, so this is a physical-device-only bug. I wired a throwaway diagnostic behind a launch flag, built and installed to my phone over devicectl, and captured the console.
The real bug: library ids aren’t catalog ids
The old code did this:
var request = MusicCatalogResourceRequest<Artist>(matching: \.id, equalTo: MusicItemID(libraryArtist.id))
request.properties = [.albums]
It took an artist id from the user’s library and asked the catalog for that id. Those are different id spaces. On device, the request doesn’t return empty quietly; it throws an HTTP error (looked like a 404) for basically every artist. That’s the console flood.
And the empty feed? A handful of artists happened to return empty instead of throwing, so the “did every artist fail?” guard never tripped. The code concluded “nothing failed, there’s just nothing new” and showed “all caught up.” The two symptoms were the same bug wearing two hats.
The fix: ask the library object for its catalog albums
Apple deliberately doesn’t expose a library item’s catalog id (it’s tucked away in a private playParameters.catalogID). The supported bridge is with(_:preferredSource:): keep the library Artist object and ask it to load a relationship from the catalog.
let enriched = try await libraryArtist.with([.albums], preferredSource: .catalog)
let catalogAlbums = enriched.albums // the artist's catalog discography
The thing I couldn’t know without the device: does preferredSource: .catalog hand back the artist’s full discography, or just the albums I already own? It’s a preference, not a guarantee, and owned-only would have meant the feed stayed empty in a new and more confusing way. So I made the diagnostic print, per artist, total / owned / the difference. The device answered clearly:
'A.N.I.M.A.L.' total=25 owned=4 cuts=21
'A$AP Rocky' total=25 owned=6 cuts=19
'Acid Bath' total=7 owned=6 cuts=1
Full discography. Real cuts after the diff. One caveat worth knowing: with(.catalog) caps .albums at about 25 without explicit pagination, which is plenty for a feed but something to remember for prolific artists.
The other number that mattered: 1,610
The diagnostic also printed my library artist count: 1,610. The old design fetched every one of those catalogs on every load. Even with the id bug fixed, that’s ~1,600 network requests to open a tab. So fixing the id was necessary but not sufficient.
I reworked the feed to be incremental. Each refresh checks a bounded batch (40 artists), caches each artist’s cuts with a last-checked timestamp, and the feed is the union of everything cached. Refreshes rotate through the library instead of re-walking it.
The part I like most is the ordering. A “discovery” tab shouldn’t prioritize the artists already in heavy rotation; it should resurface the ones you’ve forgotten. So the ranking puts artists absent from your recently-played first, then the longest-unchecked. Recently-played went from a thing I’d have weighted toward to a thing I weight against.
Lessons
- An empty state and an error state can be the same bug. The “all caught up” screen wasn’t a separate problem from the console errors; it was downstream of them. Chasing them separately would have wasted hours.
- For device-only frameworks, get on the device early. I almost designed a throttling fix around a guessed cause. The single most useful thing I did was print real counts from my real library.
- “Preferred” APIs need their semantics confirmed, not assumed.
preferredSource: .catalogcompiles whether it returns the full discography or owned-only. Only the device tells you which. - Library size is a design input. 1,610 artists turned “fetch everything” from lazy into broken.
Related reading
The mirrored window lied about the phone being unlocked
A one-line grouping fix that was already written, and three walls between it and proof: actor isolation, log stream's Mac-only scope, and a mirrored session that looks unlocked when the device isn't.
Pre-release albums, and the fix I couldn't fully verify
Apple's 'Track N' placeholders rendered as real data. The one detection signal I couldn't confirm became the one signal I stopped depending on.
A subscribe button, a MusicKit API I'd never used, and a sheet that dismisses into silence
musicSubscriptionOffer presents Apple's native sheet - and nothing in the app would ever notice it closing. The correctly-coded action that was still a functional dead end.