Steven Bleifer

← iTunes Remote

How it works.

The technical version. Roughly 13,800 lines across a dependency-free Python daemon, 26 AppleScripts and a native AppKit client — all of it arranged around one hard constraint: there is exactly one way to talk to iTunes, it is slow, and only one thing can use it at a time.

4,011 lines Python 9,770 lines Swift 26 AppleScripts No dependencies

The one bottleneck everything else is shaped around

iTunes 12.9.5 has no API. The only supported way in is Apple Events, which in practice means spawning osascript on a 2012 MacBook Pro. A trivial call — "what is playing?" — costs a few hundred milliseconds. A call that touches 25,000 playlist entries costs minutes.

Worse, Apple Events into a single app are not usefully concurrent. Two scripts in flight at once produce timeouts and, occasionally, half-applied writes. So every script in the daemon runs behind one lock:

# daemon/itunes_remote/applescript.py
RS = "\x1e"   # record separator
US = "\x1f"   # field separator

guard = self.lock if serialize else _NoLock()

That single lock is the most important line in the project, and nearly every performance bug traces back to it. It is why an eject that holds the lock for 60 seconds makes the whole client look frozen — not deadlocked, just queued. Sampling the client's main thread during one of those "freezes" showed it 97% idle. The fix was never in the client.

Consequence

Any feature that polls iTunes on a timer is a tax on every other feature. A background sync-watcher I added at an 8-second interval, running for up to four hours after a sync, turned out to be a large part of why ejecting the iPod started failing with "in use by another application." Polling is never free when there is one lock.

Scripts return text, not JSON

AppleScript has no JSON. It also has a minefield of reserved words — lines, kind, missing, removed and st all blow up as variable names in ways the error message does not explain. And string-building a script with a track title in it is an injection waiting to happen when your library contains titles with quotes, backslashes and newlines.

So scripts are files, they take input through on run argv, and they return columnar text delimited by ASCII 30 and 31 — two characters that cannot occur in an iTunes tag. The daemon splits on them:

columns = out.split("\x1e")
names, artists, albums, times = [c.split("\x1f") for c in columns[:4]]

Columnar rather than row-wise on purpose: one get of every name, then one of every artist, is dramatically faster in AppleScript than 24,000 individual property reads. Reading the iPod's contents went from minutes to seconds on that change alone.

Reading a 163 MB library without asking iTunes

Asking iTunes for the library over Apple Events is hopeless at this size. The daemon instead parses iTunes Music Library.xml directly — 163 MB, 100,648 entries, 93,829 of them music tracks. That takes about a minute — 65 s on a warm run, 117 s on a cold one.

Two consequences fall out of that number. First, the parse happens in a background thread and the old library object stays live and readable until the new one is complete, then is swapped in atomically — an early bug had the library briefly vanish for concurrent readers mid-reload. Second, a minute-plus is far too long to wait after every edit, and iTunes rewrites the XML on its own schedule anyway.

So writes are journaled. A change goes to iTunes through AppleScript and is patched into the in-memory library immediately, with a timestamped journal entry. When a fresh parse lands, journal entries newer than the XML's own write date are replayed on top and the rest discarded:

self._journal.append((now, pid, dict(fields)))
# …after a reload:
self._journal = [j for j in self._journal if j[0] > new.xml_date]

The rule throughout is that iTunes owns the data. Nothing writes to the library files on disk; every mutation goes through iTunes and the journal only mirrors what iTunes was already told.

The selection nothing can read

The hardest problem in the project has no clean solution. iTunes' iPod sync selection — which playlists, artists, albums and genres are ticked on the device pane — is stored in the binary .itl file. It is not exposed to AppleScript. And iTunes 12 exposes zero accessibility elements, even with AXEnhancedUserInterface forced on, so it cannot be read off the screen either.

Three options: guess, drive the UI, or keep your own. Guessing is wrong by construction. Driving the UI was ruled out deliberately — it breaks on any layout change and can click the wrong thing, and the one time that matters is the time it wipes a device.

So the app keeps its own selection and projects it onto a single real playlist that iTunes syncs. Four independent lists — playlists, artists, albums, genres — unioned, exactly as iTunes' own Music pane behaves. Rebuilding that playlist is chunked at 50 tracks per osascript run so a failure is bounded and progress is reportable.

The near miss

Before the first real sync, an inventory of what was already on the iPod versus what the plan covered showed the plan would have removed about 70% of the device. The selection had been reconstructed from the wrong assumption about how iTunes unions ticked items. Taking the inventory cost ten minutes; not taking it would have cost a 160 GB rebuild over USB 2.

Sorting exactly like iTunes

"Alphabetical" is doing a lot of work in that sentence. iTunes ignores a leading article, ignores quotes and apostrophes entirely, folds accents, and sorts anything starting with a digit after Z rather than before A. Getting that last part right without a custom comparator is a small trick: map digit-leading strings behind a private-use codepoint, which sorts above every letter.

# daemon/itunes_remote/library.py
_ARTICLES   = ("the ", "a ", "an ")
_QUOTES     = "\"'‘’“”«»"
_DIGITS_LAST = "\uf8ff"   # private use area, sorts after Z

def sort_form(text, override=None):
    value = fold((override or text or "").strip())
    value = _plain("".join(c for c in value if c not in _QUOTES))
    for article in _ARTICLES:
        if value.startswith(article) and len(value) > len(article):
            value = _plain(value[len(article):]); break
    if value and value[0].isdigit():
        return _DIGITS_LAST + value
    return value

_plain does NFKD normalisation and drops combining marks, so Motörhead sorts as Motorhead. Sort overrides matter too: iTunes stores a per-track "sort artist" that wins over the display name, which is how The Beatles files under B and a soundtrack credited to twelve people files under the album artist.

Three numbers, all of them true

The same library reports three different song counts, and the interesting part is that none of them is a bug.

CountWhat it is
24,153Files actually on the iPod. This is the one to trust.
24,184iTunes' own heading, which folds byte-identical files into one.
24,310Library entries the plan covers — duplicates counted separately.

The gap is re-downloaded purchases that exist twice in the library as separate entries pointing at identical audio, plus two files iTunes refuses to copy at all. Any "fix" that made the numbers agree would have been a lie about one of the three.

Album art, and why the grid was slow

Artwork has two very different costs. A cover already in memory, on disk, or embedded in the file itself comes back in about 30 ms. A cover that only iTunes knows about requires an AppleScript export — which takes the one lock, behind everything else.

The grid was asking for both kinds on the same connection pool as the player poll, so a handful of slow exports starved the entire client. The fix has three parts:

  • A ?quick=1 request answers only from memory, disk or the file, and returns 202 Accepted for anything that would need iTunes.
  • A 202 queues the cover on a priority deque that the background warmer drains first, instead of waiting for an idle moment.
  • The client fetches covers on a separate URLSession with 12 connections, so artwork can never block transport commands.

Fourteen visible albums now resolve in 0.77 s total, and Cover Flow fills immediately instead of showing placeholder discs.

Playing on the other Mac

"Play on This Mac" does not use AirPlay, because iTunes 12.9.5 cannot AirPlay to a modern Mac at all — it fails with error −15022. Instead the daemon serves the file over HTTP with range support and the client decodes it locally with AVFoundation.

That creates a subtler problem. play <track> is a one-off to iTunes, so at the end of a song iTunes simply stops rather than advancing — provided nothing is standing behind it; see the next section for what can be. There is no event for "track finished" — a deliberate stop and a finished track both report as stopped. The only available signal is where the playhead was on the previous poll:

guard mode == .remote, new.state == "stopped", new.track == nil,
      let previous = lastRemote, previous.playing, previous.duration > 0
else { return false }
return previous.position >= previous.duration - 5

Because the client steps through its own list in both modes, shuffle and repeat belong to the app rather than to iTunes. They used to be read back from iTunes, which meant they silently did nothing whenever playback was local.

What iTunes does when a song ends

This was guessed three times before it was measured, and each guess produced a fix that half worked. The measurements, on iTunes 12.9.5 with the volume at zero and each song seeked to three seconds from its end:

CommandAt the end of the song
play (track N of playlist P)stops — it does not go on to track N+1
play (first track of library playlist 1 whose persistent ID is X)stops — it does not walk the library
either of the above with once / without oncethe same
play P (the playlist itself)goes on through P and stops at its end; P is now the source
any one-off while a source standsresumes the source at its next song
play / playpause with nothing currentplays the window's selection and takes its container as the source
play an empty playlist"Parameter error"; clears nothing
a source song interrupted by a one-off, or stoppedconsumed; the source resumes after it

The source survives stop, the app closing, and days. It is drained only by playing it out or replacing it. So the app never issues play P in ordinary use; when it sees iTunes move on to a song nobody asked for, or picks up a song it did not start, the next song goes out as a one-song playlist through POST /api/queue/play — that replaces the stale source with one that is used up with the song. A related cost worth knowing: every membership change to any playlist makes iTunes rewrite the 162 MB XML, and the daemon reparses it for about 24 seconds. Eight reparses in seventeen minutes was the price of the two-playlist design this replaced; the fix writes a playlist only when a stale source has actually shown itself.

The refusal in player_cmd.applescript follows from the sixth row: play and playpause are ignored when iTunes is stopped with no current track, so iTunes is never asked to pick its own source again.

Radio: a directory, a map, a model and two players

Stations come from the Radio Browser directory (api.radio-browser.info, several mirrors, a User-Agent that names the app, one "click" reported per play so its popularity order means something). The client keeps a Query of name, tag, country code, language and a geo circle, and merges the results of several queries interleaved and de-duplicated so each search gets its best few near the top. Only one station in five carries coordinates; the rest are placed by their stated region and country through CLGeocoder, one lookup at a time with the newest view's places first, cached in radio/places.json, and drawn with a greyer pin.

Ask is two calls to the curator's model. The first turns the request into up to five queries as JSON — a genre word, an ISO country code, a place to look near, occasionally a station name — with the Style and Country menus, when set, both stated in the prompt and forced onto every query afterwards. Places are geocoded into an 80 km circle. If nothing comes back, one re-plan asks for something broader. When more than fifteen candidates come back, the second call chooses from a numbered list and gives a few words of why; the picks lead the table and everything found stays on the map.

Playing a station in iTunes over there is open location, the only way its dictionary has to play a stream, and it adds a URL track to the library every time. So the daemon remembers which URL track each address got and plays that track again by persistent ID — never by searching URL tracks by address, which takes minutes over 1,800 of them — and the script deletes the previous station's entry once the new one is playing, so the library carries at most one of the app's stations. It also checks that the current track is the stream it asked for, because a stream iTunes cannot open leaves whatever was there before, paused included, and names the entry after the station rather than the last part of the URL. iTunes 12.9.5 cannot read HLS, OGG or FLAC; those, and anything the daemon has reported it would not start, go straight to AVPlayer on the Mac the app runs on. A stream iTunes opens with no network says "playing" and sits at 0:00, so the app looks again nine seconds later and moves it here if the position has not moved. The song a station is carrying comes from iTunes' current stream title, or from ICY and ID3 timed metadata through AVPlayerItemMetadataOutput when the stream plays here; some stations send a page of Dalet XML or an iHeart tagged string as that title, and both are parsed down to artist and song.

A station is not one of the app's songs: no end-of-track timer arms for it, and while a stream is what the app asked for, iTunes seen on something else is not a stale source and not a song ending — the radio just lets go. That last rule was found the hard way, when a test instance and the real app drove iTunes at once and the real app's handoff to the next song made the test instance step to the next station.

The volume keys

Play, pause and the track keys reach the app through MPRemoteCommandCenter whenever it is the "now playing" app, which it earns by publishing what is playing through MPNowPlayingInfoCenter. Volume keys never reach an app; the system sets the Mac's own volume with them. So MediaKeyTap is a CGEvent tap on NX_SYSDEFINED events (type 14, subtype 8: the aux-control buttons), swallowing sound up, down and mute — and play, next and previous, so they work even when another app has become the now-playing app — only while iTunes over there is playing, and passing everything through otherwise. data1 carries the key in its high word and the key-down flag (0xA) in bits 8–15 of the low word. A tap that swallows needs the Accessibility permission, which the system binds to the app's code signature: an ad-hoc build loses the grant, and the keychain item holding the daemon token with it, which is why build.sh --install is always run with the Developer ID identity.

Ejecting

iTunes cannot unmount an iPod while anything holds a file open on the volume, and the daemon's own device polling counted. An eject now sets a quiet window that every reader respects before the script runs:

DEVICE_QUIET_SECONDS = 30
POD_CACHE_SECONDS = 15

The client pauses both of its device timers over the same window and resumes them if the eject fails. A failure also reads back whatever modal dialog iTunes is showing and reports it, rather than returning a bare 502 — a headless machine raises dialogs at nobody, and that alone explained a whole class of mysterious hangs.

One holder is not ours and cannot be fixed from here: diskutil reports unmounts "dissented by SystemUIServer", the menu bar agent.

Smaller things that mattered

  • Version-keyed client cache. Reads are cached against the library's version counter and dropped on any write, so a stale list cannot outlive the change that invalidated it.
  • gzip above 16 KB. JSON responses compress at level 1 only when they clear 16 KB and the client asked for it. Below that the CPU on a 2012 machine costs more than the bytes save.
  • Recently Added. Cutting to the newest 600 albums has to happen before the browser filters, not after — doing it after listed all 2,904 artists and then picked albums that contained no tracks.
  • A nullable playlist ID. A freshly created playlist has no persistent ID until iTunes rewrites the XML. Typing that field as non-optional broke playlist creation and every subsequent playlist decode, which is why drag-and-drop onto playlists also looked dead.
  • The look. macOS Mojave removed subpixel antialiasing, so text in the client is slightly softer than iTunes 10 was on the same hardware. That one is not fixable.

The curator: retrieve, then pick by number

The playlist curator is a local model, and a small one on purpose: qwen3.5:4b in Ollama, on the 24 GB M5 Air. Measured against the same 3,900-token prompt, it answers in 17–29 s (about 720 tokens/s reading, 30 writing) where gemma4:12b takes 55–60 s and names real songs no more reliably. Two calls per turn, so a turn is about half a minute.

The first design put the library's 5,000 artist names into the prompt so the model could choose among them. That was 22,000 tokens per turn — four minutes on the 12B model — and Ollama's prompt cache did not survive between the planning call and the picking call, so the reordering trick meant to rescue it did nothing. The second design keeps the model ignorant of the library and does the searching in the app:

  1. Plan. The request (or the feedback plus the conversation so far) becomes JSON: a one-sentence mood, 8–12 search phrases, up to 12 artists, things to avoid, a length, a name, and — for feedback — whether this is actually a new request in disguise.
  2. Gather. Each phrase is embedded and run against an index of every song; each artist is matched against a folded artist table (diacritics and articles dropped, "&" as "and", prefix and suffix tolerated). Results are interleaved round-robin so no source or artist swamps the list, capped at 140, grouped by artist so the model reads it like a record shelf. On feedback turns the current playlist is on the table first, marked.
  3. Choose. Candidates go in as numbered lines — 12. Sade – Smooth Operator (Diamond Life 1984) [R&B] ★4 — and the model returns numbers with a few words of reason each. Numbers cost a token or two; the 16-character persistent IDs cost eight.

The index is one 256-dimensional unit vector per song from embeddinggemma:300m, the model's 768 dimensions truncated (it is trained so a prefix still works) and renormalised. 93,829 songs are 95 MB in memory and one flat file on disk, built in the background the first time the page opens at about 130 songs a second — twelve minutes — and saved every 4,096 so a quit resumes rather than restarts. A search is a single cblas_sgemv and a partial sort. The curator works before the index finishes, on artist matching alone, but the library is sorted by artist, so a part-built index only knows the A's and B's, and the first test playlist was Atlas Sound, Augustana, Bastille and Beach House.

What the model is told and what it does are different things, so the rules live in code after the fact: only numbers that exist, no repeats, two per artist unless the request is about that artist, no holiday songs unless asked, and the asked-for count — topped up from the candidates it passed over, because the rules cost it a few picks and "20 songs" came back as 17. The decade is read from the words ("90s", "the eighties", "1994 to 1998") rather than trusted to the plan, and songs tagged outside it never reach the candidate list; songs with no year tag fill in behind the dated ones.

Feedback is the part that took three tries. Asking for a whole new list with the current one marked kept four songs of twenty. Asking for an edit — remove, add, order — was structurally better but the model named fifteen removals for "less jazz standards", so removals are capped at a third of the list unless the feedback says all, most, replace or start over. A number in the feedback sets the length; a swap keeps the length it had; "add a couple more" may grow it. Every one of those rules is a specific thing the model did wrong once.

Saving uses a new daemon feature: POST /api/playlists takes a folder, and the AppleScript makes the folder if it does not exist and creates the playlist inside it with make new user playlist at folder playlist. Playlists now carry folder and parentId from the XML, and the sidebar draws them as a tree with disclosure triangles, which iTunes had done all along and the client had been flattening.

One last trap: the client's offscreen cacheDisplay snapshot, which had produced every screenshot on these pages, renders this page with no text at all — every layer-backed AppKit label, text view and table cell comes out blank while the live window is fine. The screenshot above is a real screen capture instead, taken after waking a display that had gone to sleep while the index built.

Teaching it: memory, taste, and a fine-tune that had to find a base

The cheap layer is a memory of edits. One record per request holds the request's own 256-dimensional embedding, what the listener said, the songs that went out (by feedback or by a delete on the page), the songs that came in, and the list as saved. A fresh request is embedded and matched against those at cosine 0.62 or better, or by the same folded words. Songs removed under a matching lesson, or removed under any two lessons, are dropped before the candidate list is built; the choose prompt gets three lines of "what this listener did last time"; the planning prompt gets their past complaints so it can plan around them. The test that proved it also found a bug: an edit's top-up refilled from the candidate list, whose head is the current playlist, so the two live tracks just removed came straight back with blank reasons. The top-up now excludes what the edit took out.

Taste is a per-artist and per-genre score — play count plus three per star from three stars up, summed, squashed to 0…1 on a log scale so one band with a thousand plays does not flatten the rest. Search fetches twice the candidates it needs by meaning and re-sorts by score + 0.06·artist + 0.03·genre: embedding scores sit around 0.5–0.8, so plays break ties and never change the subject. Artists above 0.6 get a ♥ on their candidate line and a rule to prefer them between equal fits; one-star songs are never candidates.

The fine-tune is where the afternoon went. Saving a list writes the approved turns as chat-format examples — the first turn's prompt with the final list as the answer, if at least 60% of that list was on the first turn's table, and each feedback turn's prompt with the edit as actually applied. mlx-lm trains a LoRA on them with the prompt masked out of the loss, since the prompt is a 4,400-token candidate list the model is shown rather than something it should learn to write. The base model took three tries. qwen3.5:4b, the stock picker, ran out of GPU memory at the first training step even with examples cut to 2,048 tokens: three of every four of its layers are linear attention, and in training mlx-lm swaps their fused kernel for a plain scan that keeps every step's state for the backward pass. Gemma 4 E4B failed the same way. Qwen 3 4B trained happily at full length in 10 GB — and Ollama's safetensors importer then refused it, knowing Qwen 2 and Qwen 3.5 but not plain Qwen 3. Qwen 2.5 7B Instruct satisfies both: 12.7 GB peak at full length against an 18 GB working set, about 25 s a step, and a clean import. A tuned 7B against a stock 4B is a fair trade once there is data; the pipeline was proven with a ten-step run, trained, fused, quantised, imported and answering the app's JSON request. Two smaller snags on the way: mlx_lm fuse opens the base offline and the hub library rejects a snapshot missing its README, so the whole snapshot is fetched first; and mlx-lm's Qwen support needs Python 3.10 where macOS ships 3.9, so uv fetches a standalone 3.12 into the home folder with no administrator password.

In the app, the same script runs as a child process with a progress-marker environment, and the window reads its @@stage lines and mlx-lm's Iter N: lines for the bar. Every long step runs as a child of the script under a TERM trap, so Stop — or quitting the app — reaches the trainer rather than leaving it on the GPU. The app writes the picker setting itself on a clean exit, and the setup assistant's model menu lists the result as "Trained on your edits". The verification run was driven from the window by script, with two copies of the app open, and the lesson from that is small but real: System Events picks the first process by name, so with a test copy beside the installed one, target it by pid.

What it cost to build

The whole thing was written with Claude Code across six days: first commit at 10:27 pm on 2 September 2026, most recent at 8:01 pm on 7 September, over 111 commits. The six session transcripts span 156 hours between them, much of it idle time inside long-lived sessions; the first two days alone, when the daemon, the client, the sync and the curator were built, took about 19 hours of wall clock.

Four models did the work, across 3,204 API responses and 4,173 tool calls (3,285 of them shell commands). Token totals below count each response once and include cache reads, which dominate — a long agentic session re-reads a large cached context on every single turn:

ModelResponsesCache readCache writeOutputTotal
Fable 5.11,4471,642,621,35636,982,25111,305,3561,691,493,882
Opus 51,5821,276,191,50926,558,4254,890,6961,307,647,440
Opus 4.811098,862,7275,229,712345,227104,438,150
Opus 4.76519,187,5431,600,556155,76420,944,309
Total3,2043,036,863,13570,370,94416,697,0433,124,523,781

So: 3.1 billion tokens to produce 26,288 lines that survived — 20,095 of Swift in the client, 6,193 of Python and AppleScript in the daemon. The ratio is the honest part of the story — about 17 million tokens of actual output, and 180× that in re-read context. Most of the cost was not writing code. It was reading a 163 MB XML file's worth of consequences, running things on the old machine to see what iTunes really did, being wrong about counts three times before deriving the rule from the real window, and being wrong about what iTunes does at the end of a song three times before measuring it.

← Back to iTunes Remote · All projects