SongPocket: replacing the engine
I’ve always wanted to do this: I rewrote almost all of SongPocket’s internals, but because nothing’s new for users, I only bumped it to v3.5.2. 🎩
Specifically, I rewrote how it positions albums and songs, and saves those positions to storage.
The goal
SongPocket lets you reorder albums and songs, so it stores the position and Apple Music ID for each.
To do that, it used to use Core Data, which provides both runtime structures and persistence. Those are separate under the hood; you use different code to handle RAM and storage, because RAM is fast but temporary, while storage is slow but persistent.
So why replace Core Data? I’m preparing to someday switch from Media Player IDs to MusicKit IDs, which’ll require changing the persistence system anyway. I could migrate to a new Core Data model or to SwiftData, but I chose … 🥁
A PLAIN-ASS TEXT FILE
Because control. I’ve learned that controlling my software is critical for my happiness. Testing how APIs behave sucks; deciding how to solve problems is fun.
There’s a limit—I haven’t written an OS. But user data is priceless, and it makes me sick to risk destroying it by holding the tool wrong. This documentation is horrifying. Yes, Core Data should exist, but think carefully before locking your jewels in an opaque box.
I can show you. Here’s SongPocket’s Core Data storage:
How confident am I that this contains exactly my data and no more, and that I can control it perfectly for SongPocket’s entire future? Is -10 out of 5 allowed? 😨
Now here’s SongPocket’s plain-text storage:
Lines with only numbers are album IDs, and lines with an _
are song IDs within those albums. I just explained the entire format in one sentence. It’s dumb in the best way: understandable and debuggable.
So how did I make that work?
The rewrite
For persistence, I wrote some code called Disk
to write and read the file. It was my first time, but it wasn’t rocket science. Bam.
But the runtime structures. Every part of SongPocket that shows and edits albums and songs was accustomed to Core Data. So I had to rearchitect all that.
Or rather, I got to. The result is so straightforward because it suits my exact needs, and I love it.
For example, the old way to add a song:
if let existing_songs = album.contents as? [Song] {
existing_songs.forEach { song in
song.index += 1
}
}
let song = Song(context: context)
song.id = mpMediaItem.persistentID
song.container = album
song.index = 0
Versus the new way:
let id = mpMediaItem.persistentID
album.songIDs.insert(id, at: 0)
Librarian.register_songID(id, with: album)
That Librarian
thing is a chunk of RAM that lets any part of the app quickly look up album and song info, like which album contains a given song.
There are multiple ways I could’ve enabled that, and it wasn’t obvious at first which was best. I’m actually still unsure what was the best way using Core Data, because I don’t know its internals.
As a guideline, I made my caller code as straightforward as possible.
For example, SongPocket adds songs in 3 scenarios:
- Loading from storage
- Merging from Apple Music
- Migrating from Core Data
In all 3, I need to add a song ID at some position in an album. But when migrating, I actually don’t want the rest of the app to be able to look up that song yet.
let id = old_song.persistentID
new_album.songIDs.append(id)
// Don’t need:
// Librarian.register_songID(id, with: new_album)
So I didn’t give Librarian
a procedure like insert_and_register_songID
that I had to use everywhere. Instead, when migrating, I insert the song and just don’t register it. But I do register it in the other 2 cases.
I reckon if I forgot about this code for a year, this would be the easiest thing to rediscover.
So I rewrote all editing and merging code to use Librarian
’s runtime structures instead of Core Data’s. Bam.
At this point, if SongPocket were a new app, I would’ve been done. But it has existing data, so I had to migrate that.
The migration
Permanence is scary. Once you save data out there, you want to support it forever. Meanwhile, lousy apps waste storage until you delete and reinstall them, because they don’t clean up after themselves. I wanted to do it right.
I realized that a migration’s goal is to sneak in modern save data as if the old system never existed. None of the code that runs after migration should even know it happened. After SongPocket v3.5.2 starts up, it always has an albums
file and an empty Core Data database.
But also, let’s accommodate downgrading from v3.5.2 somehow, then re-upgrading. That’s useful mostly for development, but also just in case. What should happen? Let’s try to not break spacetime:
- SongPocket v3.5.1 and earlier ignore any
albums
file and operate with Core Data. They don’t know any better. - That’s the only scenario where both an
albums
file and Core Data data exist, and in that case, Core Data is the truth. - So SongPocket v3.5.2 always checks for Core Data data on startup, and if it finds any, it uses that to replace any
albums
file. - Then it destroys the Core Data database.
So it always saves your newest data in the newest form, and leaves nothing behind.
The lesson
Did I need to do all that? No.
But was it the right move? I think hell yes.
A day job probably would’ve made me migrate to another persistence system, not my own. “Don’t reinvent the wheel!”
But listen: everything I learned here is reusable knowledge. You don’t get that by only using other people’s systems. The next time I migrate, I know how to do it and why, regardless what exactly I’m moving from and to.
Just helping prevent the collapse of civilization.
Now I wanna work on the UI. In the meantime, enjoy SongPocket v3.5.2: it has an all-new engine, and zero new features*.
(*Reflecting changes to your Apple Music library is now 1.9× as fast, and first reading it is 51× as fast. Tested with 12,000 songs.)