feat(axiom-media): add Apple Music library enumeration, correct the MusicKit Now Playing model

Closes a user-reported gap: the media router covered MusicKit playback but
nothing about reading a library. New skills/music-library.md carries facts
measured on a ~99K-song library that are absent from Apple's docs.

New — music-library:
- Bulk-reading one MusicKit property across a large library leaves that task
  and every later MusicKit request unresumed for the rest of the run, no error
- Batching a library request is ~100x SLOWER (73s vs 0.4-0.9s unbatched)
- MusicKit entries are the catalog view, MPMediaPlaylist.items what is local;
  the count difference equals exactly the entries with no playParameters, and
  is permanent. Treating it as a sync signal is the expensive mistake here
- A Playlist.Entry's musicKit_persistentID is the ENTRY's id, not the song's;
  joining on it returns zero matches even at identical counts
- Song.id's FORMAT differs per device; cloudGlobalID is playlist-only
- MPMediaQuery/MPMediaPlaylist/MPMediaLibrary are API_UNAVAILABLE on native
  macOS, tvOS and watchOS (compile-proved), so MediaPlayer fallbacks are scoped

Fixed — now-playing-musickit:
- Corrected the mental model: MusicKit publishes out of process and does NOT
  write your app's dictionary, so it never overwrites a stale one you left
- The hybrid example produced the bug it warned about: pause() leaves a paused
  player owning the slot, and nothing cleared the dictionary on handoff
- Observation example read the pre-change value (objectWillChange fires first)
- Added AVPlayer-side preconditions MusicKit does not need, and why artwork
  comes back blank (Artwork is a url(width:height:) template, not an image)

Every Swift snippet compile-checked with swiftc -emit-sil against the iOS 27
SDK, including a cross-platform probe for the MediaPlayer exclusion.
This commit is contained in:
Charles Wiltgen
2026-09-05 10:48:25 -07:00
parent 627ed83dac
commit 81abbe0aa4
6 changed files with 726 additions and 58 deletions
@@ -1,6 +1,6 @@
---
name: axiom-media
description: Use when working with camera, photos, audio, haptics, ShazamKit, or Now Playing. Covers AVCaptureSession, PHPicker, PhotosPicker, AVFoundation, Core Haptics, audio recognition, MediaPlayer, CarPlay, MusicKit.
description: Use when working with camera, photos, audio, haptics, ShazamKit, the user's Apple Music library, or Now Playing. Covers AVCaptureSession, PHPicker, PhotosPicker, AVFoundation, Core Haptics, audio recognition, MediaPlayer, CarPlay, MusicKit playback and library enumeration.
license: MIT
---
@@ -46,7 +46,8 @@ license: MIT
| CarPlay templates reference (all 12 templates, availability matrix, depth limits) | See `skills/carplay-templates-ref.md` |
| CarPlay navigation reference (base view, route guidance, cluster/HUD, multitouch, voice prompts, map panels + EV charging iOS 27) | See `skills/carplay-navigation-ref.md` |
| CarPlay Now Playing template customization + sports mode | See `skills/now-playing-carplay.md` |
| MusicKit Now Playing | See `skills/now-playing-musickit.md` |
| MusicKit Now Playing, ApplicationMusicPlayer playback, subscription/authorization | See `skills/now-playing-musickit.md` |
| Enumerate the user's Apple Music **library** — MusicLibraryRequest vs MPMediaQuery, reconciling the two, playlist entries, sync, library identity, per-device `Song.id`, missing `PlayParameters`, MusicKit bulk-property pool starvation | See `skills/music-library.md` |
| DockKit motorized stands / gimbals, subject tracking, custom motor control | See `skills/dockkit.md` |
| Speech-to-text / transcription (SpeechAnalyzer, mic → transcript) | **Invoke axiom-ai** (`skills/ios-ml.md`) |
@@ -67,6 +68,7 @@ digraph media {
what -> "skills/media-intelligence.md" [label="face grouping /\nvideo highlights (OS27)"];
what -> "skills/haptics.md" [label="haptic feedback"];
what -> "skills/now-playing.md" [label="Now Playing\n/ remote commands"];
what -> "skills/music-library.md" [label="enumerate Apple Music\nlibrary / identity"];
what -> "skills/system-media-routing.md" [label="cast to non-AirPlay\n(Chromecast/DLNA, iOS27)"];
what -> "skills/screen-capture.md" [label="screen capture /\nrecording (OS27)"];
what -> "skills/carplay-hig.md" [label="CarPlay app design\n/ categories / entitlements"];
@@ -82,11 +84,12 @@ digraph media {
6. On-device face grouping (cluster faces into people across a library) or video highlights / key-frame detection? → `skills/media-intelligence.md` (`OS27`)
7. Haptics? → `skills/haptics.md`
8. Now Playing / remote commands? → `skills/now-playing.md`, `skills/now-playing-carplay.md`, `skills/now-playing-musickit.md`
9. Cast / route media to non-AirPlay devices (Google Cast/Chromecast, DLNA) as system routes? → `skills/system-media-routing.md` (`iOS27`, EU-gated/beta)
10. Screen capture / recording / streaming the screen or your own app (ScreenCaptureKit)? → `skills/screen-capture.md` (`OS27` — new on iOS/iPadOS/tvOS/visionOS 27)
11. CarPlay app design, category selection, entitlement request? → `skills/carplay-hig.md` (start here for any CarPlay work)
12. DockKit motorized stands / gimbals, subject tracking, custom motor control? → `skills/dockkit.md`
13. Want camera code audit? → Launch `camera-auditor` agent (detects deprecated APIs and architectural gaps: missing interruption handlers, runtime-error recovery, audio session deactivation, permission-denied UX, RotationCoordinator on iOS 17+; scores RELIABLE / FRAGILE / BROKEN)
9. Reading the user's Apple Music **library** (enumerate songs/playlists, library identity, sync)? → `skills/music-library.md` — a different problem from playback; read it before any library walk
10. Cast / route media to non-AirPlay devices (Google Cast/Chromecast, DLNA) as system routes? → `skills/system-media-routing.md` (`iOS27`, EU-gated/beta)
11. Screen capture / recording / streaming the screen or your own app (ScreenCaptureKit)? → `skills/screen-capture.md` (`OS27` — new on iOS/iPadOS/tvOS/visionOS 27)
12. CarPlay app design, category selection, entitlement request? → `skills/carplay-hig.md` (start here for any CarPlay work)
13. DockKit motorized stands / gimbals, subject tracking, custom motor control? → `skills/dockkit.md`
14. Want camera code audit? → Launch `camera-auditor` agent (detects deprecated APIs and architectural gaps: missing interruption handlers, runtime-error recovery, audio session deactivation, permission-denied UX, RotationCoordinator on iOS 17+; scores RELIABLE / FRAGILE / BROKEN)
## Cross-Domain Routing
@@ -108,6 +111,12 @@ digraph media {
- Audio session category/mode, `AVCaptureSession` wiring → **stay here** (avfoundation-ref, camera-capture)
- **The trap**: `CaptureInputSequenceProvider.providerWithSession(...)` (`OS27`) automatically reconfigures your app's default `AVAudioSession`. If this suite's audio-session setup "randomly breaks" after transcription is added, that's the cause — use `provider(from:in:)` and add its `captureAudioDataOutput` to your own session.
**Apple Music library + playback + persistence**:
- Enumerating the library, library identity, `PlayParameters` availability, Sync Library hazards → **stay here** (music-library)
- Queuing and playing what you found, Now Playing publishing → **stay here** (now-playing-musickit)
- Storing library rows in your own database (which column is the durable key, migrations for a re-key) → **invoke axiom-data** — but take the identity rules from music-library first; `persistentID` is not a durable key
- `MusicAuthorization` prompt copy / privacy manifest → **invoke axiom-integration** (privacy-ux reference)
**Photo library + privacy**:
- Photo picker (PHPicker, PhotosPicker) → **stay here** (photo-library) — no permissions needed
- Full PHPhotoLibrary access → **stay here** (photo-library-ref) — limited access model
@@ -134,6 +143,9 @@ digraph media {
| "ShazamKit is just SHSession + a delegate" | iOS 17+ has SHManagedSession which eliminates all AVAudioEngine boilerplate. |
| "Now Playing info is just setting metadata" | Remote commands, artwork handling, and state sync have 15+ gotchas. |
| "I'll use UIImagePickerController for photos" | PHPicker/PhotosPicker are the modern API — no permissions required. |
| "MediaPlayer reports fewer playlist members than MusicKit, so sync is incomplete" | The gap is exact and permanent — MusicKit shows the catalog, MediaPlayer shows what is local, and the difference equals the entries with no `playParameters`. A shipped guard built on this premise silently skipped 9 of 15 playlists forever. Read `skills/music-library.md`. |
| "Reading the music library is just a MusicLibraryRequest" | Reading one MusicKit property across a large library starves the cooperative pool and then wedges every later MusicKit request, with no error thrown. Batching the request is ~100x *slower*, not safer. |
| "`Song.id` is a stable key I can store" | Its *format* differs per device for the same library (`i.…` on one, bare numeric on another). Never parse it, never use it as a cross-device key. |
| "DockKit is just pairing a stand" | Custom control needs system tracking disabled, handles inverted dock states, and two different coordinate origins. |
| "Grouping faces is just Vision face detection" | Vision detects faces in one image; MediaIntelligence clusters them into persistent people (entities) across a whole library, with its own working directory and state. |
| "Casting to Chromecast means bundling the Google Cast SDK" | On iOS 27, AVSystemRouting exposes non-AirPlay routes as system routes — you adopt one Apple API (observe events, start a session, drive playbackControl) instead of a per-vendor SDK. Likely EU-gated/beta — gate and keep a fallback. |
@@ -179,6 +191,9 @@ User: "Cast to Chromecast / Google Cast without the Cast SDK" / "support non-Air
User: "Record / stream the iPad screen" / "ScreenCaptureKit on iOS" / "screen recording in my app" / "capture just my app's content"
→ Read: `skills/screen-capture.md`
User: "List every song in the user's Apple Music library" / "enumerate their playlists" / "MPMediaQuery vs MusicKit" / "sync Apple Music playlists" / "MediaPlayer and MusicKit report different playlist counts" / "my MusicKit requests stop responding after a library scan" / "store a stable id for each song across devices" / "playlists went empty after the user turned on Sync Library"
→ Read: `skills/music-library.md`
User: "Track a subject with a motorized stand" / "Control a DockKit gimbal"
→ Read: `skills/dockkit.md`
@@ -0,0 +1,521 @@
# Apple Music Library Enumeration
**Time cost**: 15-20 minutes. Two of the traps below return *plausible wrong answers* rather than errors, and unit tests cannot catch either one — the premise is wrong, not the code.
## Key Insight
**MusicKit and MediaPlayer do not enumerate the same population.** `MusicKit.Playlist.entries` is the **catalog** view; `MPMediaPlaylist.items` is what exists in **this device's local media library**. They use different identifiers, have opposite performance characteristics, and on three platforms only one of them exists at all.
Treating a count difference between them as a data-integrity signal is the single most expensive mistake in this domain. See Rule 1.
For *playing* Apple Music content and publishing Now Playing metadata, see `now-playing-musickit`. This skill covers reading the library.
## Start Here — Symptom Index
Most tasks need one or two of these, not the whole file.
| Symptom / task | Go to |
|---|---|
| The two APIs report different counts for the same playlist | Rule 1 — the difference is structural; never gate on it |
| Building a table of playlist membership | Rule 1, Rule 7 (key on `cloudGlobalID`), duplicates in Smaller Facts, and Rule 2 if you cross-reference the two sources |
| A join between MusicKit entries and MediaPlayer items returns zero | Rule 2 |
| MusicKit stops responding after a scan; no error | Rule 3 |
| A library walk is slow, or you are about to add paging | Rule 3, Rule 4 |
| Deciding which songs are playable, or building an offline queue | Rule 1 (playability), Rule 3 mitigation 1 |
| Choosing an id to persist | Rule 5, Rule 7 |
| Change detection / knowing when to re-sync | Rule 6, Rule 7 (subscribe, don't poll) |
| Empty or near-empty results | Authorization (two gates), Measurement Regime (Sync Library off) |
| Targeting macOS, tvOS, or watchOS | Platform Availability — MediaPlayer does not exist there |
## Platform Availability — Read This First
| Framework | Availability |
|---|---|
| `MusicLibraryRequest`, `MusicLibrarySectionedRequest` | iOS 16, iPadOS 16, tvOS 16, watchOS 9, visionOS 1, **macOS 14**, Mac Catalyst 17 |
| `MPMediaQuery`, `MPMediaPlaylist`, `MPMediaLibrary` | iOS 3, iPadOS, visionOS, Mac Catalyst — **`API_UNAVAILABLE(tvos, watchos, macos)`** |
**MediaPlayer's library API does not exist on native macOS, tvOS, or watchOS.** That is an explicit `API_UNAVAILABLE`, not an inferred omission — it fails to compile, it is not merely version-gated. Every "use MediaPlayer instead" mitigation below is therefore **iOS / iPadOS / visionOS / Mac Catalyst only**. On native macOS, tvOS, and watchOS, MusicKit is your only option and you must work within Rule 3 rather than around it.
## Authorization — Two Separate Gates
Neither framework returns an error when you skip its gate. Both return **empty results**.
```swift
// MusicKit required before ANY other MusicKit API
let status = await MusicAuthorization.request()
// MediaPlayer a SEPARATE gate, not covered by the MusicKit grant
let mpStatus = MPMediaLibrary.authorizationStatus()
MPMediaLibrary.requestAuthorization { status in } // iOS 9.3+
```
Two project requirements that produce no build error when missing:
- **`NSAppleMusicUsageDescription`** — required for both frameworks. Without it the consent prompt traps instead of appearing.
- **The MusicKit App Service must be enabled on your App ID.** Without it `MusicLibraryRequest` fails as empty or erroring results at runtime, never as a compile error. Same shape as the ShazamKit App Service requirement (`shazamkit`).
If someone reports "my query returns almost nothing", check both gates before anything else in this file.
## Measurement Regime
Measured 2026-09-03/05 against one real, cloud-heavy library:
- **iPad Pro 12.9" (5th gen), iPadOS 27** — 97,528 MediaPlayer songs, 99,159 MusicKit songs, 16 playlists, ~152K playlist entries
- **iPhone 16 Pro Max, iOS 27** — 771 songs before Sync Library was enabled, the full library after
One user's library, large and cloud-heavy — so it exercises the catalog/local divergence harder than a small local library would. The pool-starvation failure in Rule 3 did *not* reproduce at 771 songs. Treat thresholds as "large personal library", not constants.
**Sync Library off is its own regime.** With Settings → Music → Sync Library disabled, both frameworks return only locally-present content — 771 songs on a device whose account library holds ~99K. A near-empty result is a settings state, not a bug.
---
## Rule 1: The Two APIs Count Different Populations, and the Difference Is Exact
**Never treat `entries.count` vs `items.count` as a sync-health signal.** The gap is permanent, structural, and identical across devices.
The relationship is exact, not approximate:
```
MusicKit entries.count MPMediaPlaylist items.count
== the number of MusicKit entries whose playParameters is nil
```
| Playlist | MusicKit MediaPlayer | Entries with no `playParameters` |
|---|---|---|
| A | 1 | 1 |
| B | 1 | 1 |
| C | 73 | 73 |
| D | 110 | 110 |
| E | 53 | 53 |
Five playlists inspected in full, five exact matches, across deltas spanning two orders of magnitude. Corroborated from the other side by membership, not just count: playlist A has 185 MusicKit entries, 184 of which join to MediaPlayer members by store id, and the one non-joining entry is the one entry lacking play parameters.
The same population appears library-wide: 99,159 97,528 = 1,631, against 1,632 MusicKit songs with no `playParameters`. **That is off by one and unexplained** — a single item that one framework lists and the other does not, in the opposite direction. It does not disturb the per-playlist identity, which was exact five times out of five, but it means "exactly" is proven at playlist scale and merely near-exact library-wide.
**A caveat on `entries.count` that Rule 6 develops**: `Playlist.entries` is a *paged* collection, and Apple does not document whether `.count` reflects the whole relationship or a loaded page. The measured playlists behaved as complete (79,509 entries came back in one 0.26 s read), and the identity above is built on that. Before trusting a count on a playlist far larger than yours, drain `nextBatch()` and confirm — the identity is a claim about *populations*, not about a property of `.count`.
**What the extra entries actually are** was never established. They are entries with no local representation — plausibly catalog-only additions, region-unavailable tracks, or items pulled from the catalog. The identity is measured; the *characterisation* is not. Do not tell users these tracks are "unavailable" without checking which case you have.
```swift
// WRONG compares incommensurable quantities; skips real work forever
if mpPlaylist.items.count < musicKitPlaylist.entries?.count ?? 0 {
skipMembershipSync() // fires permanently on any cloud-heavy playlist
}
// CORRECT do not gate on the counts at all. Membership is what
// MediaPlayer returns; the MusicKit surplus has no local row to key to.
writeMembership(from: mpPlaylist.items)
// 🔍 DIAGNOSTIC ONLY explains a gap; must never gate a write
let localRepresentable = entries.filter { $0.playParameters != nil }
// localRepresentable.count == mpPlaylist.items.count
```
The filter is the *explanation*, not the fix. If you ship it inside a conditional you have rebuilt the guard this rule exists to prevent — just with a better predicate.
**And the diagnostic is itself a bulk property read, so Rule 3 governs it.** `playParameters` over a playlist's entries is bounded by the *playlist's* size, not the library's — fine at the 185 entries of playlist A, and squarely in the hazard at the 79,509 entries measured on another. Only 2,000 entries per playlist was measured safe. Above that, do not reconcile by sweeping `playParameters`: compare `catalogId``playbackStoreID` joins on a bounded page (Rule 2), or accept the count difference as expected and do not gate on it at all — which is the real lesson here anyway.
### Playability is not one predicate, and the wrong one costs twice
`playParameters != nil` means **"MediaPlayer can represent this locally"** — it does not mean playable offline. A cloud song that has never been downloaded has play parameters and still needs the network.
If you are building an *offline* queue, the predicate you want is `includeOnlyDownloadedContent` (MusicKit) or an `isCloudItem == false` query predicate (MediaPlayer) — Rule 3, mitigation 1. Getting this wrong costs twice: you reach for the bulk sweep Rule 3 forbids in order to evaluate a predicate that was not the one you meant.
MediaPlayer has the same class of trap under different names:
| Property | Meaning |
|---|---|
| `MPMediaItem.assetURL` (iOS 8+) | **Nullable.** nil for cloud and DRM-protected items — no local file to open. An export or analysis pass over `assetURL` finds most of a cloud-heavy library nil. |
| `isCloudItem` (iOS 8+) | Lives in iCloud Music Library, not on device. Marked `// filterable`. |
| `hasProtectedAsset` (iOS 9.2+) | DRM-protected; no direct asset access. Marked `// filterable`. |
Check `isCloudItem` / `hasProtectedAsset` before reaching for `assetURL` — and prefer filtering on them in the query over reading them per item.
### What this costs when you get it wrong
A design document reviewed five times and signed off recorded one playlist's "229 members through MediaPlayer against 311 through MusicKit" as evidence of a *transient replication artifact*. It is this rule — permanent, structural, and identical on both devices twelve hours apart.
A guard built on that reading skipped playlist membership whenever MediaPlayer's count was below MusicKit's. It fired on **9 of 15 playlists, on both devices, on every pass, permanently** — and sat ahead of the forced-mode check, so no user action could override it. Most playlists would never populate.
Roughly 2,500 unit tests could not catch it, because the premise was wrong rather than the code. It took a device probe printing both counts side by side, plus the `playParameters` breakdown, to establish that the difference was structural.
---
## Rule 2: A Playlist Entry's `musicKit_persistentID` Is the Entry's ID, Not the Song's
Decoding a `Playlist.Entry`'s `playParameters` yields:
```
keys = [catalogId, id, isLibrary, kind, musicKit_databaseID,
musicKit_libraryID, musicKit_persistentID]
kind = "_playlistEntry"
musicKit_persistentID = "-8337673474215285654"
```
Entry values are **negative** and cluster in a narrow range within a playlist (…285654, …373765, …373808 — near each other, not strictly consecutive). A genuine `MPMediaItem.persistentID` is positive. Sign is the reliable tell; adjacency is corroborating, so do not lead with it against a skeptic.
**The trap**: joining MusicKit entries to MediaPlayer members by `persistentID` returns **zero matches even on playlists where both APIs report identical counts** — and a column of zeroes reads like a finding rather than a broken join. Measured: 0 across all 16 playlists, including ones matching 23/23 and 1/1.
`PlayParameters` has no public members, so JSON is the only way in. Decode it — the dictionary below is a real type, because `catalogId`'s JSON type is not guaranteed to be a string:
```swift
private struct EntryPlayParameters: Decodable {
let kind: String?
let catalogId: String?
private enum CodingKeys: String, CodingKey { case kind, catalogId }
init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
kind = try c.decodeIfPresent(String.self, forKey: .kind)
if let s = try? c.decodeIfPresent(String.self, forKey: .catalogId) { catalogId = s }
else if let n = try? c.decodeIfPresent(Int64.self, forKey: .catalogId) { catalogId = String(n) }
else { catalogId = nil }
}
}
// WRONG silently zero, including on exact-count playlists
member.persistentID == entryParams.musicKit_persistentID
// join on the store id, where catalogId is present
member.playbackStoreID == entryParams.catalogId // MPMediaItem, iOS 10.3+
```
**`playbackStoreID` has a sentinel.** Items with no catalog identity — ripped CDs, unmatched uploads — report `"0"` or empty. Filter those out of your index or they collide:
```swift
mpPlaylist.items.filter { !$0.playbackStoreID.isEmpty && $0.playbackStoreID != "0" }
```
**This join is one-directional.** It finds the local partner of a catalog entry. A locally-ripped track has an `MPMediaItem` but no `catalogId` on the MusicKit side, so it is structurally unjoinable by this key — that is a property of the data, not a bug to chase.
**An untested alternative worth probing.** `entry.item` (`.song`/`.musicVideo`) exposes the *item's own* `playParameters`, whose `musicKit_persistentID` would be the item's library id rather than the entry's — which would join to `MPMediaItem.persistentID` directly and cover local-only tracks that `catalogId` cannot. It is the obvious next place to look once you know the entry id is the wrong key, so you have probably already thought of it. **It has not been verified on device**, and the `catalogId` join above is the one that was. Dump `entry.item?.playParameters` alongside `entry.playParameters` and compare the hit rates before relying on it.
### The same key name means different things at two levels
This is what makes the trap survive review:
| Object | `musicKit_persistentID` in its `playParameters` |
|---|---|
| `Playlist` | The **MediaPlayer playlist id**, as a signed `Int64` bit pattern. Usable. Its `catalogId` equals `MPMediaPlaylist.cloudGlobalID`. |
| `Playlist.Entry` | The **entry's own id** (`kind = "_playlistEntry"`). Negative. Joins to nothing in MediaPlayer. |
**Do not persist the entry id either.** It is unique within a read, which makes it tempting as a membership-row key. Nothing establishes that it is stable across reads, across a Sync Library toggle, or across devices — it is an undocumented internal identifier, and Rule 5's posture applies. For membership rows use `(playlist, position)`, which is stable by construction and handles duplicates.
`PlayParameters` has no public members at all — only `Equatable`, `Hashable`, `Sendable`, and a `Codable` conformance — so JSON-encoding is the only way to see inside, and nothing in the type system distinguishes these two cases for you.
```swift
let json = try JSONEncoder().encode(playlist.playParameters)
```
---
## Rule 3: Never Bulk-Read MusicKit Properties
**The requests are fast; the properties are lazy.** `MusicLibraryRequest<Song>` unbatched returns all 99,159 in 0.4-0.9 s. Iterating *one property* over that result is what kills it.
Measured: reading `playParameters` on all 99,159 `Song` values from a cooperative-pool task produced about 35 seconds of pool starvation — a `Thread`-hosted heartbeat recorded six consecutive ≥5 s waits for a fresh detached task. After the pool recovered, **the reading task, its watchdog, and every subsequent MusicKit request were never resumed for the remainder of the run**. The process stayed alive and the main actor kept responding throughout.
**The starvation is a symptom, not the mechanism — do not reason from it.** Hosting the same loop on a plain `Thread`, which does not draw on the cooperative pool at all, lost MusicKit identically. Two consequences: moving the work off the pool does **not** help, and any fix reasoned from "I'll relieve the executor" — yields, detached tasks, a private queue — is reasoning from the wrong model.
**The mechanism is unknown, and saying more than that is a mistake.** Nothing crashed, nothing threw, and no corruption was observed — what was observed is that requests stopped returning. Do not describe this as MusicKit "crashing", "corrupting state", or being "unrecoverable": none of those were measured, and a restart clearing it is equally consistent with a stuck connection, an exhausted internal budget, or an unresumed continuation. The rule below stands on the *observation*, and does not need the cause.
Scope of the evidence: one device, one OS build, two hosting variants (cooperative-pool task and plain `Thread`), plus a main-actor run that completed. Not observed to recover; not proven unrecoverable.
```swift
// WRONG loses the task, and every later MusicKit request with it
let response = try await MusicLibraryRequest<Song>().response()
for song in response.items where song.playParameters != nil { }
```
`song.playParameters` *reads* like a stored-property access. It is a computed property backed by MusicKit's property store, and resolving it across a large collection does work the type signature does not advertise.
**On the main actor the loop completes** (2.7 s for `playParameters`, 62 s for seven fields) — 62 seconds of unresponsive UI is watchdog-termination territory, so completing there is not a fix.
### Mitigations, strongest first
**1. Push the predicate into the query so the sweep never happens.** This is the best option and the only one that works on every platform. Both frameworks can filter before anything reaches you:
```swift
// MusicKit request-level flag, on the cheap side of the requests/properties line
var request = MusicLibraryRequest<Song>()
request.includeOnlyDownloadedContent = true
// MediaPlayer isCloudItem and hasProtectedAsset are marked `// filterable`
// in MPMediaItem.h, so "downloaded" is expressible as a query predicate
let query = MPMediaQuery.songs()
query.addFilterPredicate(MPMediaPropertyPredicate(
value: false, forProperty: MPMediaItemPropertyIsCloudItem))
query.addFilterPredicate(MPMediaPropertyPredicate(
value: false, forProperty: MPMediaItemPropertyHasProtectedAsset))
```
Reach for this before anything below. Neither flag's behaviour was measured at ~99K, so verify the cost on your own library — but a predicate the daemon evaluates cannot trigger a client-side property sweep by construction.
**2. Read bulk fields from MediaPlayer instead** — iOS / iPadOS / visionOS / Catalyst only (see the availability table). Not available on native macOS, tvOS, watchOS, which is why option 1 matters there.
**3. Restrict MusicKit to small record sets.** ~15 playlists is fine; the library is not.
**4. Cap per-entry inspection.** 2,000 entries in one playlist did not reproduce the hazard. **The measurements leave a 50x gap** between that and the 99,159 that did, and they do not establish whether the budget is per sweep or per process — 16 playlists × 2,000 in one run is untested. Treat 2,000 as the largest number anyone has evidence for, not as a proven ceiling.
**5. Load properties for a bounded set** with `with(_:)`. Note the demonstrated properties are *relationships*; whether `with(_:)` changes anything for an **attribute** like `playParameters` is untested, so do not assume it converts a bulk attribute sweep into a safe one:
```swift
let detailed = try await song.with([.albums, .artists])
let fromLibrary = try await song.with([.albums], preferredSource: .library)
```
`MusicPropertySource` is `.catalog` or `.library` (`.library` is macOS 14 / macCatalyst 17+).
**No MusicKit work on the main actor whose size you do not control.** An ordinary bounded request is fine there — `MusicLibraryRequest<Playlist>` is 1 ms, and `response()` is `async` so it suspends rather than blocks. What must never run there is a read whose cost scales with the library.
---
## Rule 4: Batching a Library Request Is ~100x Slower
| Approach | 99,159 songs |
|---|---|
| One unbatched `MusicLibraryRequest<Song>` | 0.4-0.9 s |
| `limit = 500` plus `nextBatch()` | 73 s |
```swift
// CORRECT one request, whole library
let request = MusicLibraryRequest<Song>()
let response = try await request.response() // 0.4-0.9 s for ~99K
// WRONG ~100x slower for the same result
var batched = MusicLibraryRequest<Song>()
batched.limit = 500
var all = try await batched.response().items
while let next = try await all.nextBatch() { all += next } // 73 s
```
`limit` and `offset` earn their place for **windowed** reads you never intend to complete:
```swift
var recent = MusicLibraryRequest<Song>()
recent.sort(by: \.libraryAddedDate, ascending: false)
recent.limit = 5
let items = try await recent.response().items // 3 ms - 0.5 s
```
**Memory**: holding all 99,159 `Song` values costs about 115 MB. Release the collection once you have projected out what you need.
---
## Rule 5: Never Parse or Persist `Song.id`
**MusicKit `Song.id` is not stable across devices, and its *format* is not stable across devices.** The same library, same Apple ID, produced `i.…` style identifiers on one device and a bare numeric string equal to the MediaPlayer `persistentID` on the other.
```swift
// WRONG the format differs per device; there is no documented grammar
if song.id.rawValue.hasPrefix("i.") { }
let numeric = Int64(song.id.rawValue) // nil on one device, fine on the other
```
`MusicItemID` is `RawRepresentable` over `String`, so the raw value is *available*. That is not permission to interpret it.
### What identity you can actually rely on
| Identifier | Stable across devices? | Notes |
|---|---|---|
| `MusicKit Song.id` | **No** | Format itself varies per device. Never parse. |
| `MPMediaItem.persistentID` | **No** | Differs per device for the same song. |
| `MPMediaPlaylist.persistentID` | **No** | Also re-keys on a Sync Library toggle (Rule 7). |
| `MPMediaPlaylist.cloudGlobalID` | **Yes** | iOS 14+, `MPMediaPlaylist` only. The durable playlist key. |
| `MPMediaItem.playbackStoreID` | Catalog-scoped | iOS 10.3+. The join target for MusicKit `catalogId` (Rule 2). |
| Title + artist | **No** | Not identity: one lookup returned three distinct `MPMediaItem`s. |
`cloudGlobalID` exists on `MPMediaPlaylist` and **not** on `MPMediaItem` — there is no equivalent durable per-song key from MediaPlayer.
Also measured: MusicKit's library `Album.id` equalled the same album's catalog id, but `Artist.id` did **not**. Do not generalize a matching id on one entity type to the others.
---
## Rule 6: Optional Properties With Undocumented `nil` Conditions
**`Playlist.lastModifiedDate`** is `Date?` (iOS 15+). Apple documents no conditions under which it is nil. **It is nil in practice**, and there is no fallback: `MPMediaPlaylist` has no per-playlist modification date at all. Its complete property set is `persistentID`, `cloudGlobalID`, `name`, `playlistAttributes`, `seedItems`, `descriptionText`, `authorDisplayName`. The only MediaPlayer date is `MPMediaLibrary.default().lastModifiedDate`, which is **library-wide** (an instance property — there is no class accessor).
Consequence: any change-detection scheme keyed on a per-playlist modification date must handle a permanent nil, and **cannot distinguish "unknown" from "unchanged"** unless you encode that distinction yourself.
```swift
// WRONG nil silently means "unchanged", so the playlist never re-syncs
if playlist.lastModifiedDate ?? .distantPast > lastSeen { resync() }
// CORRECT nil is a third state, not a default
switch playlist.lastModifiedDate {
case .some(let d) where d > lastSeen: resync()
case .some: break // known unchanged
case .none: resyncOnSchedule() // unknown, not unchanged
}
```
**`Playlist.entries`** is `MusicItemCollection<Playlist.Entry>?` — optional, nil until loaded, and a **paged** collection. Apple does not document whether `.count` reflects the complete relationship or only a loaded page. Do not build a correctness argument on that count alone.
`Playlist.Entry` carries its own `title`, `artistName`, `artwork` and `playParameters`, so it reads like a `Song`; the actual item lives in `entry.item`, typed `Playlist.Entry.Item?` (`.song` or `.musicVideo`).
```swift
// Both optionals are real, and non-song entries exist
guard let entries = try await playlist.with([.entries]).entries else { return }
for entry in entries {
guard let item = entry.item else { continue }
if case .song(let song) = item { }
}
```
Loading `entries` for *every* playlist is a MusicKit property read across a set — the shape Rule 3 forbids. It is acceptable only because playlists number in the tens (16 on the measured device). **The bound is the playlist count; confirm yours is small.**
---
## Rule 7: Sync Library Rebuilds, and Nameless Rows Have Two Possible Causes
When the user turns on **Settings → Music → Sync Library**, the library is rebuilt underneath your app:
- **Every `MPMediaPlaylist.persistentID` changed.** Sixteen 20-digit ids became small consecutive numbers. **Every `cloudGlobalID` was unchanged.** A local-only playlist stopped being listed by either API.
- **Songs were never observed partial** — 770 before, then the full 97,528 / 99,159 at both later readings. Intermediate states were not observed: absence of evidence, not a guarantee.
- **Local song `persistentID`s were kept.** Only *playlist* ids re-keyed. If your table is keyed to `MPMediaItem.persistentID`, the toggle does not orphan it — but the playlist rows above it will orphan unless keyed on `cloudGlobalID`. This asymmetry is the single most important durability fact here, and it cuts the opposite way for the two tables.
```swift
// WRONG persistentID is not a durable playlist key across a sync toggle
store.upsertPlaylist(key: mpPlaylist.persistentID)
// CORRECT cloudGlobalID survives the re-key
store.upsertPlaylist(key: mpPlaylist.cloudGlobalID ?? localFallback(mpPlaylist))
```
### Nameless, empty playlist rows
Two nameless rows with zero members were observed on the iPhone, unchanged across two readings twelve minutes apart. **The cause is unresolved**, and there are at least two candidates:
1. **Mid-rebuild replica state** from the Sync Library toggle.
2. **Phantom pagination records.** An Apple Media Engineer has confirmed for the Apple Music API surface that *"there is also a feature for supporting pagination where 'phantom' playlist records may still be returned in the response so that the offsets do not change when making paginated requests"*, with a report of 81 nameless phantom playlists, and that deletions may take a minute or more to propagate. Those remarks concern MusicKit JS / the web API; **whether the same mechanism reaches Swift MusicKit's `entries` is unverified.**
Not reproduced on the iPad (zero nil titles across ~12,000 entries).
Either way the handling is the same, and it is the safe handling under both hypotheses: **never delete local rows because a playlist came back empty or nameless.** Mark it missing and let a later pass revive it.
Nothing measured licenses an eventual purge. A genuinely emptied playlist will therefore keep stale rows indefinitely under this rule — if you need to reclaim them, make it an explicit product decision with a threshold you own, not an inference the sync pass draws on its own.
### Do not poll for library change — subscribe
```swift
MPMediaLibrary.default().beginGeneratingLibraryChangeNotifications()
NotificationCenter.default.addObserver(
forName: .MPMediaLibraryDidChange, object: nil, queue: nil
) { _ in /* re-evaluate cached queries */ }
```
The header says so outright: *"Any items or playlists which were previously cached should be re-evaluated from queries when `MPMediaLibraryDidChangeNotification` is posted."* Also `API_UNAVAILABLE(tvos, watchos, macos)` — on native macOS you are left with stamp comparison.
---
## Read Costs
Measured on the iPad, off the main actor unless stated.
| Read | Cost |
|---|---|
| `MPMediaQuery.songs().items`, one materialisation | 0.5 s |
| `persistentID` over the materialised 97,528 items | 0.02 s |
| All 20 converter fields over 97,528 items | 38 s |
| Every `MPMediaPlaylist.items` materialised once, `persistentID` over 152,156 members | 0.3 s |
| `MPMediaItem.artwork != nil` presence check over all 97,528 items | ~28 s, three runs |
| `MPMediaItem.artwork` over all 97,528 items, forcing a decode | did not finish in 10 minutes, twice |
| `MusicLibraryRequest<Song>`, one unbatched request, all 99,159 | 0.4-0.9 s |
| The same, `limit = 500` with `nextBatch()` | 73 s |
| `MusicLibraryRequest<Playlist>` | 1 ms |
| One playlist's `entries`, 79,509 of them | 0.26 s |
| Sorted by `libraryAddedDate`, `limit = 5` | 3 ms - 0.5 s |
| Holding all 99,159 `Song` values | ~115 MB |
Three things to take from this beyond the raw numbers:
1. **Enumeration is cheap; per-item fields are not.** 97,528 items in 0.5 s; 20 fields off them in 38 s.
2. **`artwork` presence and `artwork` content are different operations.** Checking `!= nil` across the library is ~28 s and fine. Forcing a decode did not terminate. Never load artwork in a walk — load it for what is on screen.
3. **MediaPlayer bulk reads off the main actor are safe** on this device; MusicKit's are not (Rule 3).
---
## Smaller Facts Worth Knowing
- **Playlists contain duplicates.** Measured: 184 items / 173 distinct, 279 / 262, and 18,766 / 18,762 on three playlists. Code comparing `items.count` against a distinct count will be silently wrong.
- **MusicKit omits folders.** 15 MusicKit playlists against MediaPlayer's 16; the missing one is a folder. Neither framework has a folder API — `MPMediaPlaylistAttribute` is `None | OnTheGo | Smart | Genius`, there is no parent-ID property, and no folder case in `MPMediaGrouping`. "No MusicKit twin" is the only available signal and it is a **proxy**: any join failure looks identical to a folder.
- **Counts do not differ per device.** Both devices reported identical MusicKit entry counts for the same cloud playlist (79,509). An early hypothesis that MusicKit answers differently per device was tested and disproved — so a per-device difference is a bug in your code, not the framework.
- **Writing is a separate surface.** `MusicLibrary.shared.add(_:to:)`, `createPlaylist(...)`, `edit(...)` — out of scope here, but MusicKit is not read-only.
---
## Sorting and Filtering
`MusicLibraryRequest` filters and sorts server-side through typed key paths:
```swift
var request = MusicLibraryRequest<Song>()
request.filter(matching: \.artistName, equalTo: "Brian Eno")
request.sort(by: \.libraryAddedDate, ascending: false)
let items = try await request.response().items
request.includeOnlyDownloadedContent = true
request.filter(text: "ambient")
```
For grouped presentation use `MusicLibrarySectionedRequest`, whose API mirrors the above with `filterItems` / `sortItems` and `filterSections` / `sortSections`:
```swift
var sectioned = MusicLibrarySectionedRequest<Album, Track>()
sectioned.sortSections(by: \.title, ascending: true)
let sections = try await sectioned.response().sections
```
**One narrow platform carve-out**, easy to overstate: only `MusicLibrarySectionedRequest.filterItems(matching:contains:)` — its two `String` overloads — is unavailable on macOS and Mac Catalyst. `filterSections(matching:contains:)`, the free-text `filterItems(text:)` and `filterSections(text:)`, and **every** overload on the non-sectioned `MusicLibraryRequest` are available on all platforms.
---
## Choosing the Framework
Every **MediaPlayer** row is iOS / iPadOS / visionOS / Mac Catalyst only.
| Need | Use | Why |
|---|---|---|
| Enumerate songs, ids only | Either | MusicKit 0.4-0.9 s unbatched; MediaPlayer 0.5 s |
| Many per-item fields over the whole library | **MediaPlayer** | MusicKit bulk property reads lose the task (Rule 3) |
| Playlist *list* and catalog-side entries | **MusicKit** | `MusicLibraryRequest<Playlist>` 1 ms; entries 0.26 s for 79K. Bounded by playlist count, not library size |
| Membership rows keyed to **local items** | **MediaPlayer** | `MPMediaPlaylist.items` *is* the local population by definition and carries `persistentID` directly — 0.3 s for 152K members. Do not source these from MusicKit and then try to resolve them down |
| Joining entries to local items | **Both** | MusicKit `catalogId``MPMediaItem.playbackStoreID` (Rule 2) |
| Change notification | **MediaPlayer** | `MPMediaLibraryDidChangeNotification`; MusicKit has no equivalent |
| A durable cross-device playlist key | **MediaPlayer** | `cloudGlobalID`; MusicKit has no equivalent |
| Per-playlist modification date | Neither | MusicKit's is nil in practice; MediaPlayer has none (Rule 6) |
| Queue something for playback | **MusicKit** | `PlayParameters`; see `now-playing-musickit` |
| Folders | Neither | Observed as a count delta only; no folder API in either |
Real apps on iOS end up using both. That is the expected outcome, not a design smell.
---
## Anti-Rationalization
| Thought | Reality |
|---|---|
| "MediaPlayer shows fewer members than MusicKit — sync must be incomplete" | The gap is exact and permanent: it equals the number of entries with no `playParameters`, because MusicKit shows the catalog and MediaPlayer shows what is local. A shipped guard built on this fired on 9 of 15 playlists, on every pass, forever. |
| "Both APIs report the same count, so my join is working" | Joining entries by `musicKit_persistentID` returns zero matches even at 23/23 and 1/1. Entry ids are negative and belong to the entry, not the song. Join `catalogId``playbackStoreID`. |
| "`musicKit_persistentID` means the same thing everywhere" | On a `Playlist` it is the MediaPlayer playlist id and is usable. On a `Playlist.Entry` it is the entry's own id (`kind = "_playlistEntry"`) and joins to nothing. Same key, two meanings. |
| "`song.playParameters` is just a property read" | It is a computed property backed by MusicKit's store. Across a large library it leaves every later MusicKit request unresumed for the rest of the run, with no error. |
| "The pool starved, so I'll move it off the cooperative pool" | Pool starvation is the visible symptom, not the cause — a plain `Thread`, which never touches the pool, lost MusicKit identically. Any fix aimed at the executor is aimed at the wrong thing. |
| "I'll batch the request so it doesn't hang" | Batching is ~100x *slower*: 73 s against 0.4-0.9 s. The requests are fast; the properties are lazy. |
| "I'll do the bulk read on the main actor since that works" | It completes — after 62 s of unresponsive UI. Watchdog-termination territory, not a fix. |
| "I'll just use MediaPlayer everywhere, it's the bulk-safe one" | `MPMediaQuery`, `MPMediaPlaylist` and `MPMediaLibrary` are `API_UNAVAILABLE(tvos, watchos, macos)` — they do not compile on native macOS, tvOS or watchOS, where `MusicLibraryRequest` does exist. |
| "It worked on my test device" | Not reproduced at 771 songs; reproduced at 99,159 both on a cooperative-pool task and on a plain `Thread`. A small library proves nothing about this failure. |
| "`Song.id` is a string, I can parse it" | The format differs per device: `i.…` on one, bare numeric on another. Parsing yields a lookup that fails silently, on the second device only, at restore or handoff time — which is why it survives testing. |
| "`lastModifiedDate` is nil, so nothing changed" | Apple documents no nil conditions and it is nil in practice, with no MediaPlayer fallback — `MPMediaPlaylist` has no modification date at all. nil is "unknown", and treating it as "unchanged" means never re-syncing. |
| "`items.count` tells me how many distinct songs are in the playlist" | Playlists contain duplicates — 184 items / 173 distinct on one measured playlist. |
| "An empty nameless playlist means the user emptied it" | Two candidate causes, neither confirmed for Swift MusicKit: a mid-rebuild replica, or Apple's documented phantom pagination records. Mark missing, never delete — that is correct under both. |
| "My query returns nothing, so the API is broken" | Both frameworks return **empty, not an error**, when their authorization gate is unmet — two separate gates, plus `NSAppleMusicUsageDescription` and the MusicKit App Service on the App ID. |
## Resources
**WWDC**: 2022-10148, 2022-110347, 2026-254
**Docs**: /musickit/musiclibraryrequest, /musickit/musiclibrarysectionedrequest, /musickit/playlist, /musickit/playparameters, /musickit/musicauthorization, /mediaplayer/mpmediaquery, /mediaplayer/mpmediaplaylist, /mediaplayer/mpmedialibrary, /mediaplayer/mpmediaitem/playbackstoreid
**Skills**: now-playing-musickit, now-playing, music-understanding, shazamkit
@@ -3,19 +3,27 @@
**Time cost**: 5-10 minutes
**Scope**: playback, authorization, subscription, and Now Playing publishing. For *reading the user's library* — enumerating songs and playlists, library identity, `PlayParameters` availability, sync — see `skills/music-library.md`. Do not walk the library with the patterns here; a bulk MusicKit property read can wedge the framework for the life of the process.
## Key Insight
**MusicKit's ApplicationMusicPlayer automatically publishes to MPNowPlayingInfoCenter.** You don't need to manually update Now Playing info when playing Apple Music content.
**`ApplicationMusicPlayer` publishes Now Playing itself — but it does NOT write your app's dictionary.** It is a client of the Music playback service, which renders the audio out of process and publishes the metadata from there. `MPNowPlayingInfoCenter.default()` holds info about **your process** ("the current application", per the header). The two are separate publishers of one Lock Screen slot.
That distinction is the whole skill. Get it wrong and you reason as if MusicKit will overwrite a stale dictionary you left behind. **It will not.** Whatever wrote last wins, and MusicKit publishes *asynchronously, after `play()` returns* — which is why the resulting bugs are intermittent rather than consistent.
## What's Automatic
When using `ApplicationMusicPlayer`:
When using `ApplicationMusicPlayer`, the playback service publishes:
- Track title, artist, album
- Artwork (Apple's album art)
- Duration and elapsed time
- Playback rate (playing/paused state)
The system handles all MPNowPlayingInfoCenter updates for you.
It keeps these current across queue auto-advance, remote-control skips, and seeks — state your app never sees synchronously. A dictionary you write at `play()` time is a snapshot that goes stale at the first track change.
**A paused `ApplicationMusicPlayer` still owns the slot.** It still has a current entry, so it keeps republishing. `pause()` does not hand ownership back; only `stop()` does. See Hybrid Apps.
Two caveats on the evidence, so you know how far to trust the model. The out-of-process split is confirmed by the header (`MPNowPlayingInfoCenter` "holds now playing info about the current application"); the paused-player-keeps-publishing behaviour is **inferred from that ownership model and from the observed symptom**, not from a documented guarantee. And whether the service republishes on its own schedule — which would make a stale dictionary self-heal at the next auto-advance — is **unestablished**. If you see a hybrid bug that "sometimes fixes itself", that is the likely reason, and the handoff rules below are correct either way.
## What's NOT Automatic
@@ -151,32 +159,39 @@ class MusicKitPlayer {
### Observing Playback State
`ApplicationMusicPlayer.Queue` and `MusicPlayer.State` are both `ObservableObject`. **Prefer binding them directly in SwiftUI**`@ObservedObject` handles the update timing for you:
```swift
@MainActor
class PlayerViewModel: ObservableObject {
private let player = ApplicationMusicPlayer.shared
@Published var isPlaying = false
@Published var currentEntry: ApplicationMusicPlayer.Queue.Entry?
@Published var playbackTime: TimeInterval = 0
struct NowPlayingBar: View {
@ObservedObject private var queue = ApplicationMusicPlayer.shared.queue
@ObservedObject private var state = ApplicationMusicPlayer.shared.state
func observeState() {
// Observe playback status
Task {
for await state in player.state.objectWillChange.values {
isPlaying = player.state.playbackStatus == .playing
}
}
// Observe current entry (track changes)
Task {
for await queue in player.queue.objectWillChange.values {
currentEntry = player.queue.currentEntry
}
var body: some View {
if let entry = queue.currentEntry {
Text(entry.title)
Image(systemName: state.playbackStatus == .playing ? "pause.fill" : "play.fill")
}
}
}
```
If you must observe imperatively, mind the timing — **`objectWillChange` fires *before* the mutation**, so reading in the same iteration returns the OLD value:
```swift
// WRONG reads the pre-change value; the UI lags one track behind
for await _ in player.queue.objectWillChange.values {
currentEntry = player.queue.currentEntry
}
// CORRECT yield so the mutation lands before you read
for await _ in player.queue.objectWillChange.values {
await Task.yield()
currentEntry = player.queue.currentEntry
}
```
Bind `_`, not a named variable: `objectWillChange` emits `Void`, so a named binding is unused and warns.
---
## Queue Management
@@ -246,54 +261,105 @@ if let entry = player.queue.currentEntry {
## Hybrid Apps (Own Content + Apple Music)
If your app plays both Apple Music and your own content:
**The hard part is the handoff, not the playback.** Two publishers, one slot — and the two classic symptoms are both handoff bugs:
| Symptom | Cause |
|---|---|
| Switch to your file, Lock Screen still shows the Apple Music track | You called `pause()`, not `stop()`. A paused player keeps its entry and keeps republishing over you. |
| Switch to Apple Music, Lock Screen *sometimes* shows your old file | Your stale dictionary was never cleared, and/or an AVPlayer observer is still firing. MusicKit publishes asynchronously after `play()` returns, so it is a race — hence "sometimes". |
Three rules make it deterministic:
1. **Tear the old engine down before starting the new one**`stop()` MusicKit (never `pause()`); cancel AVPlayer observers and `replaceCurrentItem(with: nil)`.
2. **Clear your dictionary on every switch** (`nowPlayingInfo = nil`), then never write it while Apple Music is the source. Guard the writer on the source so call-site discipline isn't the only defence.
3. **Never read-modify-write.** `var info = center.nowPlayingInfo ?? [:]` merges your elapsed time into Apple Music's title and artwork. Always assign a complete dictionary.
```swift
import MusicKit
import MediaPlayer
import AVFoundation
@MainActor
class HybridPlayer {
final class HybridPlayer {
enum Source { case none, appleMusic, ownContent }
private let musicKitPlayer = ApplicationMusicPlayer.shared
private var avPlayer: AVPlayer?
private var currentSource: ContentSource = .none
private let avPlayer = AVPlayer()
private var source: Source = .none
private var statusObservation: Task<Void, Never>?
private var track: OwnTrack?
enum ContentSource {
case none
case appleMusic // MusicKit handles Now Playing
case ownContent // We handle Now Playing
}
func playAppleMusic(_ song: Song) async throws {
// 1. Silence every in-process writer FIRST.
statusObservation?.cancel(); statusObservation = nil
avPlayer.pause()
avPlayer.replaceCurrentItem(with: nil)
track = nil
func playAppleMusicSong(_ song: Song) async throws {
// Switch to MusicKit
avPlayer?.pause()
currentSource = .appleMusic
// 2. Withdraw our dictionary a stale one races MusicKit's async publish.
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
// 3. Hand over. publish() is now a no-op by construction.
source = .appleMusic
musicKitPlayer.queue = [song]
try await musicKitPlayer.play()
// MusicKit handles Now Playing automatically
}
func playOwnContent(_ url: URL) {
// Switch to AVPlayer
musicKitPlayer.pause()
currentSource = .ownContent
func playOwnContent(_ track: OwnTrack) async throws {
musicKitPlayer.stop() // stop(), NOT pause()
try AVAudioSession.sharedInstance().setActive(true)
avPlayer = AVPlayer(url: url)
avPlayer?.play()
// Manually update Now Playing (see skills/now-playing.md)
updateNowPlayingForOwnContent()
source = .ownContent
self.track = track
avPlayer.replaceCurrentItem(with: AVPlayerItem(url: track.url))
observeAVPlayer()
avPlayer.play()
// publish() runs from the observer on the first REAL status change.
}
private func updateNowPlayingForOwnContent() {
var nowPlayingInfo = [String: Any]()
nowPlayingInfo[MPMediaItemPropertyTitle] = "My Track"
// ... rest of manual setup
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
private func observeAVPlayer() {
statusObservation?.cancel()
statusObservation = Task { [weak self, avPlayer] in
// `options: [.new]` is load-bearing. The default is [.initial], which emits
// `.paused` synchronously at subscription BEFORE play() publishing a
// rate-0 entry immediately after stop() instead of when playback starts.
for await status in avPlayer.publisher(for: \.timeControlStatus, options: [.new]).values {
guard let self, !Task.isCancelled else { return }
switch status {
case .playing: publish(rate: 1)
case .paused: publish(rate: 0)
default: break
}
}
}
}
/// The ONLY place that writes the info center. Guarded on source.
private func publish(rate: Float) {
guard source == .ownContent, let track else { return }
let elapsed = avPlayer.currentTime().seconds
MPNowPlayingInfoCenter.default().nowPlayingInfo = [
MPMediaItemPropertyTitle: track.title,
MPMediaItemPropertyPlaybackDuration: track.duration,
MPNowPlayingInfoPropertyElapsedPlaybackTime: elapsed.isFinite ? elapsed : 0,
MPNowPlayingInfoPropertyPlaybackRate: rate,
]
}
}
```
**Preconditions for the AVPlayer half**, all easy to miss because MusicKit needs none of them. Miss these and you can fix the handoff perfectly and still have no Lock Screen entry for your own content:
- `AVAudioSession` category `.playback`, activated.
- `UIBackgroundModes``audio` in Info.plist. MusicKit playback survives backgrounding without it; AVPlayer does not, and the Lock Screen entry vanishes with the audio.
- **Registered `MPRemoteCommandCenter` targets.** Register once at launch and dispatch on `source` — for own content drive `AVPlayer`, for Apple Music forward to `ApplicationMusicPlayer` (usually a no-op, since the service handles its own transport).
**Artwork for your own files** is the other half of the artwork story below, and it is where rule 3 actually gets violated. Load embedded art from `AVAsset.commonMetadata`, wrap it in `MPMediaItemArtwork`, and **re-assign the whole dictionary** — the async completion is the classic site for `var info = center.nowPlayingInfo ?? [:]`, which, if it lands after a switch, grafts your file's artwork onto Apple's title. Guard the completion on both `source` and the track id.
**No periodic timer.** Write elapsed time and rate on play / pause / seek only and let the system extrapolate. A per-second timer is precisely the late writer that produces the intermittent symptom.
`MPNowPlayingSession(players:)` with `automaticallyPublishesNowPlayingInfo` (iOS 16+) automates the AVPlayer side, but how it arbitrates against MusicKit's out-of-process publisher is unverified here — don't introduce it to fix a handoff bug.
---
## Common Mistake
@@ -316,6 +382,8 @@ func playAppleMusicSong(_ song: Song) async throws {
}
```
**Why the manual write yields blank artwork.** `MusicKit.Artwork` is a **URL template**, not an image — its accessor is `url(width:height:)`, and `MPMediaItemPropertyArtwork` needs an `MPMediaItemArtwork` wrapping a real `UIImage`. A dictionary written synchronously at `play()` time therefore ships with no artwork *and* overwrites the artwork the service already had. Two symptoms, one cause.
## When to Use Manual Updates with MusicKit
Only override MPNowPlayingInfoCenter if:
@@ -329,4 +397,4 @@ Only override MPNowPlayingInfoCenter if:
**Docs**: /musickit, /musickit/applicationmusicplayer, /musickit/musicsubscription
**Skills**: skills/now-playing.md, skills/now-playing-carplay.md
**Skills**: skills/now-playing.md, skills/now-playing-carplay.md, skills/music-library.md
+1
View File
@@ -58,6 +58,7 @@ Comprehensive guides and documentation for Apple platform development. Reference
| [**mapkit-ref**](./mapkit-ref) | MapKit API — SwiftUI Map, MKMapView, annotations, search, directions |
| [**network-framework-ref**](./network-framework-ref) | Network.framework API — NWConnection (iOS 12-18), NetworkConnection (iOS 26+), TLV framing, Coder protocol |
| [**networking-migration**](./networking-migration) | Network framework migration guide — NWConnection to NetworkConnection transition patterns |
| [**music-library**](/skills/integration/music-library) | Apple Music library enumeration — MusicLibraryRequest vs MPMediaQuery, the bulk-property hazard, library identity, Sync Library replica behavior |
| [**now-playing-carplay**](./now-playing-carplay) | CarPlay Now Playing integration — MPNowPlayingInfoCenter, transport controls, CarPlay framework |
| [**now-playing-musickit**](./now-playing-musickit) | MusicKit Now Playing — MusicPlayer, queue management, system integration |
| [**photo-library-ref**](./photo-library-ref) | Photo Library API — PHPickerViewController, PhotosPicker, PHAsset, photo selection patterns |
+2
View File
@@ -44,9 +44,11 @@ This page documents the `axiom-media` reference skill -- MusicKit-specific Now P
- For core Now Playing setup (MPNowPlayingInfoCenter, remote commands, artwork), see [now-playing](/skills/integration/now-playing)
- For CarPlay integration, see [now-playing-carplay](/reference/now-playing-carplay)
- For *reading* the user's library rather than playing it — enumerating songs and playlists, library identity, sync — see [music-library](/skills/integration/music-library). Do not walk the library with the playback patterns here; a bulk MusicKit property read can stop the framework responding for the rest of the run.
## Related
- [music-library](/skills/integration/music-library) Reading the Apple Music library (MusicLibraryRequest vs MPMediaQuery, identity, sync); the other half of MusicKit
- [now-playing](/skills/integration/now-playing) Core Now Playing patterns (manual MPNowPlayingInfoCenter setup)
- [now-playing-carplay](/reference/now-playing-carplay) CarPlay Now Playing with CPNowPlayingTemplate customization
- [avfoundation-ref](/reference/avfoundation-ref) AVAudioSession and AVPlayer for non-MusicKit audio content
+61
View File
@@ -0,0 +1,61 @@
---
name: music-library
description: Enumerating the user's Apple Music library — MusicLibraryRequest vs MPMediaQuery, the bulk-property hazard, library identity, and Sync Library replica behavior
skill_type: discipline
apple_platforms: iOS 16+, iPadOS 16+, tvOS 16+, watchOS 9+, visionOS 1+, macOS 14+ (MusicKit); MediaPlayer is iOS/iPadOS/visionOS/Mac Catalyst only
---
# Apple Music Library Enumeration
Reading the user's Apple Music library is a different problem from playing it. Two frameworks — MusicKit's `MusicLibraryRequest` and MediaPlayer's `MPMediaQuery` — enumerate the same library with different contents, different identifiers, and opposite performance characteristics. This skill covers which to reach for, and the failure modes that only appear on a large library.
## When to Use
Use this skill when you're:
- Listing a user's songs, albums, artists, or playlists from their Apple Music library
- Building a local index or database of library content
- Deciding which identifier to persist for a song or playlist
- Reconciling what MusicKit reports against what MediaPlayer reports
- Debugging MusicKit requests that stop responding partway through a scan
- Handling what happens to your data when the user toggles Settings → Music → Sync Library
For *playing* what you found and publishing Now Playing metadata, use [now-playing-musickit](/reference/now-playing-musickit) instead.
## Example Prompts
- "List every song in the user's Apple Music library."
- "MediaPlayer says this playlist has 229 members but MusicKit says 311 — which is right?"
- "My join between MusicKit playlist entries and MediaPlayer items returns zero matches."
- "My MusicKit requests stop responding after I scan the library — no error, they just never return."
- "What identifier should I store for a song so it matches on the user's other device?"
- "Some songs in my queue silently refuse to play."
- "The user turned on Sync Library and now half their playlists show as empty."
- "Should I use MPMediaQuery or MusicLibraryRequest?"
## What This Skill Provides
- **The count-reconciliation rule** MusicKit shows the *catalog*, MediaPlayer shows what is *local*, and the difference between them is exact rather than a sync fault. Treating it as a health signal is the most expensive mistake in this domain, and the skill carries the case where it silently disabled a feature on most of a user's playlists
- **The playlist-entry join trap** why `musicKit_persistentID` on a `Playlist.Entry` means something different than on a `Playlist`, and why the wrong join returns zero matches instead of an error
- **The bulk-property rule** why reading one MusicKit property across a large library leaves the reading task, and every later MusicKit request, never resuming for the rest of the run, with no error thrown. The mechanism is unknown and the skill says so: pool starvation is the visible symptom, but the same loop on a plain `Thread` fails identically, so fixes aimed at the executor are aimed at the wrong thing
- **Two authorization gates that fail silently** MusicKit and MediaPlayer each have their own, and an unmet gate returns *empty results*, not an error; plus the Info.plist key and App ID service that produce no build error when missing
- **The batching inversion** one unbatched request is roughly 100x *faster* than `limit` + `nextBatch()`, with measured numbers
- **A measured read-cost table** enumeration, per-item fields, artwork presence vs decode, playlist entries, and memory, taken on a ~99K-song library
- **Identity rules** why `Song.id`'s *format* differs per device, which identifiers survive a Sync Library toggle, and what `cloudGlobalID` does and does not cover
- **Optional properties with undocumented `nil`** `Playlist.lastModifiedDate` is nil in practice with no MediaPlayer fallback, so "unknown" and "unchanged" must be distinguished by you
- **Sync Library behavior** playlist re-keying, and nameless-empty rows with both candidate causes named rather than one asserted
- **A framework decision table** MusicKit vs MediaPlayer per task, and why real apps on iOS use both
## Platform Caveat
MediaPlayer's library API (`MPMediaQuery`, `MPMediaPlaylist`, `MPMediaLibrary`) is marked `API_UNAVAILABLE` on native macOS, tvOS, and watchOS — it does not compile there, while `MusicLibraryRequest` does. Much of the common advice for this problem assumes MediaPlayer is always available as a fallback. It isn't, and the skill's guidance is scoped accordingly.
## Measurement Note
The numbers and failure modes in this skill were measured on a specific large library (an iPad running iPadOS 27 with ~99,000 songs), and the bulk-property failure did **not** reproduce on a 771-song library. The skill states its regime so you can judge whether it applies to your data — treat the thresholds as "large personal library", not as constants, and note where it marks a claim as reasoned-but-unmeasured rather than measured.
## Related
- [now-playing-musickit](/reference/now-playing-musickit) playback, authorization, subscription, and Now Playing publishing; the other half of MusicKit
- [Now Playing](/skills/integration/now-playing) Lock Screen and Control Center metadata for your own content
- [music-understanding](/skills/integration/music-understanding) on-device analysis of a track's key, tempo, and structure, once you have the audio
- [ShazamKit](/skills/integration/shazamkit) identifying an unknown song, which is catalog matching rather than library reading