Tunes
- 6 minutes read - 1190 wordsiTunes Match worked great for years: you uploaded your library, Apple matched what it could, and you had access to everything from any device. But Apple is clearly moving everyone toward Apple Music, and iTunes Match has been slowly rotting. The writing was on the wall.
I didn’t want to subscribe to Apple Music. I wanted to keep my library, my ratings, my play counts, my playlists, all the metadata accumulated over 20 years. So I built my own streaming setup: a Go backend that serves the library over HTTP, and SwiftUI apps that play it on iOS, macOS, and tvOS.
Exporting the library
Apple Music (formerly iTunes) can export the library as an XML plist file. It’s a flat dictionary of tracks keyed by ID, with every field you’d expect: name, artist, album, play count, rating, date added, file location, and about 60 more.
<key>1234</key>
<dict>
<key>Track ID</key><integer>1234</integer>
<key>Name</key><string>Windowlicker</string>
<key>Artist</key><string>Aphex Twin</string>
<key>Album</key><string>Windowlicker</string>
<key>Total Time</key><integer>381573</integer>
<key>Location</key><string>file:///Music/Aphex%20Twin/Windowlicker/01%20Windowlicker.m4a</string>
</dict>The Go struct that maps to this uses plist tags for XML parsing and gorm tags for database storage, with every optional field as a pointer for proper nil handling:
type ITunesTrack struct {
TrackID *int `plist:"Track ID" json:"track_id" gorm:"primaryKey"`
Name *string `plist:"Name" json:"name" gorm:"index"`
Artist *string `plist:"Artist" json:"artist" gorm:"index"`
Album *string `plist:"Album" json:"album" gorm:"index"`
TotalTime *int `plist:"Total Time" json:"total_time"`
Location *string `plist:"Location" json:"location"`
Rating *int `plist:"Rating" json:"rating" gorm:"index"`
PlayCount *int `plist:"Play Count" json:"play_count"`
DateAdded *time.Time `plist:"Date Added" json:"date_added" gorm:"index"`
// ... 60+ more fields
}The initial version parsed the XML on every startup using howett.net/plist. That worked fine for a 15,000-track library (about 2 seconds of parsing). But it eventually moved to SQLite with GORM for persistence, which also enabled FTS5 full-text search and pre-aggregated album caches.
The Go backend
The server is straightforward. A LibraryService holds the database, artwork cache, rate limiter, and various sub-services. Routes are plain net/http handlers with a JWT auth middleware:
// Auth routes (no middleware)
http.HandleFunc("/auth/login", authHandlers.HandleLogin)
http.HandleFunc("/auth/register", authHandlers.HandleRegister)
// Library routes (auth required)
http.Handle("/tracks", authMiddleware(http.HandlerFunc(service.HandleTracks)))
http.Handle("/artists", authMiddleware(http.HandlerFunc(service.HandleArtists)))
http.Handle("/albums", authMiddleware(http.HandlerFunc(service.HandleAlbums)))
http.Handle("/stream/", authMiddleware(http.HandlerFunc(service.HandleStream)))
http.Handle("/artwork/track/", authMiddleware(http.HandlerFunc(service.HandleTrackArtwork)))The /stream/{id} endpoint serves audio files directly with http.ServeFile, which handles range requests and conditional caching automatically. No need to build a custom streaming protocol. HTTP does the job.
Authentication uses Argon2 for password hashing and session tokens stored in the database. It later grew WebAuthn support for passkey login, and a QR code flow for tvOS (where typing passwords with a remote is painful).
Album artwork
This was the most annoying part. The exported XML doesn’t include artwork. The actual cover images are either embedded in the audio files or live somewhere in Apple’s ecosystem that isn’t accessible after export.
The solution is a three-tier artwork system:
- Embedded extraction: read the audio file metadata with
github.com/dhowden/tag, pull out the cover image if present, cache it locally - iTunes Store lookup: search Apple’s public API for the album, download the highest resolution artwork available
- Manual upload: for albums that don’t match either source
The iTunes Store lookup uses a scoring system to match results. Exact album + artist name match scores highest, partial matches score lower, and anything below a minimum threshold is rejected:
func (ls *LibraryService) searchITunesStore(artist, album string, tracks []ITunesTrack) *ITunesSearchItem {
searchTerm := fmt.Sprintf("%s %s", strings.TrimSpace(artist), strings.TrimSpace(album))
params := url.Values{}
params.Set("term", searchTerm)
params.Set("entity", "album")
params.Set("media", "music")
params.Set("limit", "10")
searchURL := fmt.Sprintf("https://itunes.apple.com/search?%s", params.Encode())
// ... fetch, decode, find best match by score
}Apple’s API returns 100x100 thumbnails by default. The URL pattern is predictable (replace 100x100 with 600x600), but not all resolutions exist for all albums. The server tries 600, 512, and 300 with HEAD requests before falling back to the original.
The real constraint is rate limiting. Apple’s Search API starts returning 403s if you hit it too hard. The server uses a channel-based rate limiter that caps requests to 2 per second by default, configurable via TOML config.
The gapless playback problem
This one took a while to figure out. Live albums, DJ mixes, classical recordings: any music that’s supposed to flow continuously between tracks gets a gap when played back on iOS.
The root cause: iOS AVFoundation ignores the iTunSMPB gapless metadata in MP3 files. This is the atom that tells the player how many encoder delay and padding samples to skip. Apple’s own Music app reads it. AVFoundation does not.
The fix is converting MP3s to AAC. The AAC container stores gapless information in a way that AVFoundation actually respects. The server has a built-in transcoder:
transcoder := tunes.NewTranscoderService(service, tunes.TranscodeOptions{
Concurrency: 4,
DeleteSource: false, // Keep original MP3s
})
results, err := transcoder.ConvertAllMP3s(context.Background())It uses ffmpeg under the hood, runs 4 workers in parallel, keeps the originals, and updates the database paths. A 15,000-track library takes a couple of hours. After that, gapless playback works perfectly.
SwiftUI clients
The app targets iOS, macOS, and tvOS from a single codebase, with platform-specific extensions for navigation and layout. iOS uses tab navigation with a mini-player overlay, macOS uses a split view with sidebar, and tvOS uses focus-based navigation for the remote.



The audio player is built on AVQueuePlayer for seamless track transitions. It pre-buffers the next track 10 seconds before the current one ends, maintaining up to 3 items in the queue at any time.
One hard-won lesson: @Published property updates in background cause iOS to terminate the app for excessive main-thread UI work. The fix is gating all published writes behind a foreground check:
struct PlaybackState: Equatable {
var currentTime: TimeInterval = 0
var duration: TimeInterval = 0
var isPlaying = false
var isLoading = false
static func == (lhs: PlaybackState, rhs: PlaybackState) -> Bool {
return Int(lhs.currentTime) == Int(rhs.currentTime) &&
Int(lhs.duration) == Int(rhs.duration) &&
lhs.isPlaying == rhs.isPlaying &&
lhs.isLoading == rhs.isLoading
}
}Grouping related properties into structs (instead of individual @Published vars) and implementing coarse equality comparison reduced SwiftUI re-renders significantly. The player only triggers a view update when the second changes, not on every time observer tick.
The API layer uses Combine publishers with automatic 401 retry: if a request fails with an expired token, the service transparently refreshes it and replays the original request. Network monitoring via NWPathMonitor lets the app degrade gracefully when offline, falling back to Core Data for cached data.
What grew out of it
What started as “stream my library from a Raspberry Pi” has accumulated features over time:
- Full-text search with SQLite FTS5 across all track metadata
- Play history tracking: every play gets logged with duration and completion status, which feeds a yearly replay feature (like Spotify Wrapped)
- Lyrics: embedded USLT frames from audio files, plus LRClib API integration for synced timestamps
- Offline mode: Core Data persistence with background sync, download management for keeping albums locally
- Discogs integration: artist biographies and metadata from a separate database
- Vector similarity search: track embeddings for “genius playlists” based on audio features and metadata
It’s not a replacement for Apple Music. It doesn’t have a catalog of 100 million songs. But it plays my music, with my metadata, on all my devices, without a subscription. That was the goal.