plex-itunes-sync

live

Sync iTunes playlist collections to Plex — albums, track playlists, and record label metadata.

pythonplexitunesmusicopen-source

The problem

My music library lives in iTunes. It's been building for most of my life — every album tagged, organized, and managed there. iTunes is the source of truth. It's not going anywhere.

I organize my music into collections: curated groups of albums that I shuffle when I want to actually listen to something. Without collections, a library of thousands of albums is just an undifferentiated mass that's essentially inaccessible. iTunes doesn't have a "collection" concept natively — just playlists — so I use playlists set to album view, drag and drop entire albums in, and treat them as collections.

I listen to music in Plex. My iTunes library lives on my desktop and syncs to the Plex server via [Syncthing](https://syncthing.net) — a continuous, peer-to-peer file sync tool. (This alone is relatively uncomplicated.) Plex does have Collections. The problem is that there's no bridge between the two. Every time I updated an iTunes playlist, I'd have to manually recreate it in Plex. And I never did, because that would take forever. So my collections just... didn't exist in Plex. I'd go to listen to music and either shuffle the entire library or not bother.

I sat on this problem for a very long time before finally building a fix. If you have this exact niche setup — iTunes on Windows, Plex on Linux, and a deep need for organizational control — you are not alone, and I have the solution for you.

What it does

A Python script that reads your iTunes Library.xml, matches albums and tracks to Plex, and creates or updates the targets. Three sync modes:

  • **Collections** — iTunes playlist → Plex Collection (album-level). Albums are deduplicated from the playlist's tracks. If the collection already exists, it diffs and only adds/removes what changed.
  • **Playlists** — iTunes playlist → Plex Playlist (track-level, order preserved). Useful for mixes and queues where sequence matters.
  • **Labels** — iTunes playlist → Plex album studio field. Tag entire groups of albums with their record label. Handles multi-label conflicts via an overrides file.

The pipeline

Running sync.py does the following in order:

  • Parse iTunes Library.xml using plistlib (247 MB on my machine; ~25s first run). Result is serialized to disk using pickle — Python's built-in binary object serialization — and reused on subsequent runs (~1s) unless the XML's mtime or file size has changed. Pickle stores the parsed Python object tree directly, so there's no re-parsing overhead.
  • Connect to Plex via the PlexAPI and fetch all albums and collections into in-memory indexes — one HTTP call each, then all matching is O(1) in memory.
  • For each configured sync mapping, extract albums (or tracks) from the iTunes playlist and match them against the Plex index.
  • Diff the current state of the Plex collection/playlist against the desired state, then add and remove only what changed.
  • Print a sync report: matched, unmatched, added, removed, already-present.

Matching

Matching iTunes albums to Plex albums is the hard part. iTunes (Windows + macOS heritage) stores strings as NFD Unicode; Linux/Plex typically uses NFC. Japanese characters, accented Latin, and special symbols all look identical but compare as different bytes. Artist names also differ between how iTunes tagged an album and how Plex scraped metadata from MusicBrainz.

The script uses a four-tier matching cascade:

  • Tier 1: NFC-normalized (artist, title) — exact after unicode normalization
  • Tier 2: NFC-normalized title only — when artist strings differ between sources
  • Tier 3: Casefolded + NFC (artist, title) — case-insensitive exact
  • Tier 4: Casefolded + NFC title only — loosest in-memory match
  • API fallback: Plex's own search API, which is accent-insensitive and catches things the index misses
  • Path fallback: translates the iTunes file path to the expected Plex path and searches by filename

Configuration

A YAML config file covers the Plex connection, path mapping (Windows iTunes paths → Linux Plex paths), and sync rules. Path mapping handles the fact that iTunes on Windows sees files at one path while Plex on Linux sees them at another — the script translates between them automatically.

Collections, playlists, and labels are each configured as a map of "iTunes Playlist Name": "Plex Target Name". All three sections are optional — if you only care about collections, leave the others empty.

Non-destructive

The script only reads the iTunes XML and manages Plex metadata. Music files are never touched. Running with --dry-run shows exactly what would change without making any modifications.

It's also idempotent — running it twice produces the same result. If nothing in iTunes has changed, Plex collections are reported as already up to date and nothing is written.

Efficiency and stability

A few design choices worth calling out:

  • **XML caching with Pickle.** The iTunes library XML is large and slow to parse. After the first run, the parsed Python object is serialized to a .pickle file next to the script. On subsequent runs, the script checks the XML's modification time and file size — if either changes, it re-parses; otherwise it loads the pickle in under a second. This makes repeated syncs fast regardless of library size.
  • **Pre-fetched in-memory indexes.** All albums, collections, and tracks are fetched from Plex in a single API call each and stored as lookup dicts keyed by normalized strings. Matching is O(1) per album — no per-album API calls, no N+1 query patterns.
  • **Batched writes.** Plex's API has a URI length limit. The script adds and removes items in batches of 20 to stay within it reliably.
  • **Graceful diffing.** For each collection and playlist, the script computes the exact delta — what needs to be added, what needs to be removed, what's already correct — and only writes the diff. Re-running after a partial failure is safe.
  • **Conflict resolution.** Albums that appear in multiple label playlists are flagged as conflicts and written to a label_overrides.yaml file. You pick the winner, re-run, and it's applied. The override file is gitignored (it's personal data) and auto-updated when new conflicts are discovered.

How I actually run it

You can run it as a plain script. I run it with AI.

Clone the repo, open it in Cursor or Claude Code, and let it index the codebase. Then tell it: "Do a dry run and show me what would change." The script outputs a human-readable sync report — every collection, how many albums matched, what would be added or removed, anything that didn't match. Review it, then tell the AI to proceed with the live sync.

This works well because the codebase is small and well-documented, the dry-run output is structured and readable, and the AI has full context on every decision the script makes. Whenever I add new music to iTunes and want to sync, that's the workflow — a two-step ask with a review in between.