I told you in Part 1 that Prologue and my audiobook collection were going to suck the most. I wasn't wrong, just early.

Part 4 solved this exact problem for video. WatchState exists, it's mature, and it does the job. For audiobooks, nothing like that exists. I had to build it.

This is the story of that script, and it's also a decent stand-in for the whole migration so far. Plex wasn't just a media server. It was the thing every other piece of my setup quietly assumed would always be there.


The Real Cost of Decoupling

Splitting one monolith into several focused tools is the right call. I said as much in Part 2, when Navidrome took over music duty instead of asking Jellyfin to do a worse job of something Plex already did fine.

Jellyfin does support audiobooks, technically. But going from Plex to Jellyfin for audiobooks would have meant running straight back into the same problem music almost had: a video-first platform bolting on a feature it was never built around.

Audiobookshelf exists because it was built for exactly one job. Same logic that sent music to Navidrome sends audiobooks to Audiobookshelf. That part of the decision was easy.

The part nobody warns you about is what happens to everything that was quietly wired into Plex along the way. Watch history. Listening position. Per-user state.

None of that lives in a neutral, portable format. It lives inside Plex's database, shaped exactly the way Plex needed it shaped, and getting it out means understanding a schema nobody documented for this purpose. Decoupling a monolith doesn't just mean swapping the server. It means rebuilding every quiet assumption that monolith was making on your behalf.


Before we begin lets talk about the AI in the room.

I want to tell you up front about this, I vibe coded this entire migration script. I spent time doing the research, working out how things work and in the end but Claude Code and I cracked out this script together and tested it as much as I could outside of my own library. With that ethical disclaimer out of the way, let's get started with attempting to migrate your audiobooks.

Where Plex Actually Stores Audiobook Progress

My assumption going in was that audiobook progress would live at the album level. An audiobook is one item in your library, so the position you're at should live on that item.

It doesn't. Plex stores listening position at the individual track level, the same as it would for a song on an album. A multi-file audiobook isn't one progress value. It's a pile of per-file offsets that happen to belong to the same parent. It could be one A4b or 100 wav, mp3 etc files.

To get a usable position, you have to find the most recently played file in the book, then add up the full duration of every file that comes before it, then add that file's own offset. Only then do you have a single number that represents where the listener actually is.

Plex also doesn't separate audiobook progress by account the way you'd hope. Two people in your household listening to the same book produce two completely separate sets of rows, keyed by Plex account ID. Nothing anywhere indicates they're the same physical file unless you go looking for it.

And then there's duplication. Re-scans and re-imports over the years left several of my books with multiple album-level entries, all sharing the same external ID, each with its own slightly different progress. Plex doesn't clean this up on its own.


None of This Existed Already

Before writing anything, I looked for prior art. People migrate from Plex to Audiobookshelf often enough that I assumed someone had solved this.

Nobody had, at least not publicly. The tooling that exists for syncing watch state, the same WatchState project that handled Part 4 for me, is built around video. It has no concept of an audiobook library, let alone the track-to-book aggregation problem above. It would be cool to have Audiobookshelf be added as a backend to WatchState but I doubt the author wants to deal with this mess.

Audiobookshelf's own API supports writing progress directly, which meant the building blocks were there. The bridge between the two wasn't.

So I vibe coded one.


What the Script Actually Does

The full thing is on GitHub, but here's the shape of it.

It reads directly from Plex's SQLite database, no Plex API involved, because the API doesn't expose this data in a form that's useful for bulk migration. For each audiobook, it walks every track belonging to that book, finds the one with the most recent last_viewed_at timestamp, and reconstructs an absolute position from there.

Duplicate album entries get collapsed by matching on the book's external ID (its Audnex identifier, in my case), keeping whichever duplicate was most recently played and discarding the rest.

Per-user separation runs through Plex's account_id. Each person you want to migrate maps to one Audiobookshelf API token in a config file, and the script processes one person at a time.

Running it against the wrong account is the difference between migrating your progress and migrating your spouse's.

Title matching turned out to be the part that bit me hardest.


The Title Matching Problem

Plex and Audiobookshelf agree on almost nothing about how a title should be formatted. Heck, Plex isnt event made for audiobooks, it was hacked in. Plex pulls in subtitle and series information that Audiobookshelf strips entirely:

Plex:  "The Alchemist: A Fable About Following Your Dream"
ABS:   "The Alchemist"

Plex:  "Sunrise on the Reaping (The Hunger Games): A Hunger Games Novel"
ABS:   "Sunrise on the Reaping"

My first pass handled this with a loose fallback: if an exact match failed, check whether either title contains the other as a substring. That felt reasonable for about ten minutes.

Then I ran it against my own account and watched The Alchemist get matched to a Stephen King book called UR. The string "ur" is sitting right there inside "alchemist" once you normalize case and punctuation, and my matcher was happy to call that a hit.

A few entries down, We Are Legion (We Are Bob) got matched to a Russian-language Jane Austen collection, for the same reason. Short, loosely-matched fragments turn into noise once your library has a few hundred books in it.

The fix was a matching cascade instead of one clever rule. Try an exact match first. If that fails, strip everything after a colon and try again. If that fails, strip parenthetical asides. If that fails, strip a trailing series suffix like ", Book 3."

Only after all of that fails does it fall back to substring matching, and only for titles long enough that a false match is actually unlikely.

That last gate matters more than it looks. Short titles need to fail outright and get flagged for a manual look, not get force-fit into whatever string happens to contain them.


Why This Is Worth Open Sourcing

This is a narrow problem. Plex-to-Audiobookshelf migrations aren't common enough that there's a thriving ecosystem of tools fighting over the use case. But narrow doesn't mean nobody else needs it. It means the few people who do need it have nowhere to look.

The script is modular on purpose. A config file holds your Plex database path, your Audiobookshelf URL, and a token per person you're migrating. The matching logic, the Plex reading logic, and the Audiobookshelf API client are each separated into their own file instead of one script doing all three jobs at once.

If your matching needs differ from mine, or your account structure is different, you can change one piece without touching the rest.

It's on GitHub now, dry-run by default, so you can see exactly what it intends to write before it writes anything. Look, it isnt perfect so you could have your own LLM 5 years from now read it and then make a more up-to-date version of it if you want. Thats my goal here, vibe code it out and then let someone else vibe code their own version of it.


The Bigger Pattern

Every part of this series so far has been some version of the same discovery: Plex wasn't just a media server, it was a set of assumptions baked into everything around it. Music assumed Plex. Watch history assumed Plex. Audiobook progress assumed Plex in a way that turned out to be more tangled than any of the others, because nobody had bothered to untangle it before.

Decoupling each piece has been real work, not because any single piece is hard, but because each one hides its own version of "wait, how does this actually store data?"

The upside is that what comes out the other side isn't locked to anything. Navidrome doesn't care what happens to Jellyfin. Audiobookshelf doesn't care what happens to either of them. These are all open source projects I'm trying to use to replace a software that I bought a lifetime license for, plexpass. They are ruining it for others with their crazy pricing model for lifetime licenses and a bunch of people are building various rafts to abandonship. In all of this I'm trying to build a framework for migrating from Plex to all the other apps in the opensource ecosystem that I had Plex doing. Movies, TV shows, music, audiobooks, pictures.

If I ever want to move off one of these tools again, I'm not starting from zero. I've already done the hard part of learning where the data actually lives, now you do as well.

That's the trade I'm making across this whole series. More pieces, more moving parts, and every single one of them replaceable on its own terms. At least when Jellyfin dies we can go and listen to music instead of what I had to do when Plex died, go outside and touch grass and sneeze because Im alergic.


Read the rest of the series: Plex to Jellyfin

Migrated anything similarly stubborn off Plex? Drop it in the comments.