How it works.
The technical version. About 1,170 lines of shell and Python that move an album between two Macs without ever letting iTunes see a half written file, plus a C sensor reader and a small Swift app to watch the whole thing. Written up properly because a few people asked.
The constraint
iTunes watches a folder and imports whatever shows up in it. It uses FSEvents, it scans on launch, and it does not wait for whoever is writing to finish. If it catches a file mid write you get half a track, imported and filed, and you don't find out for months.
iTunes is closed source. I can't change it, there's no lock protocol, and there's no way to tell it a file is finished. So the whole design comes down to one requirement: make the album showing up in that folder a single event that can't be observed halfway through.
There's exactly one tool for that. rename(2) is atomic within a filesystem. The destination name either doesn't exist yet or refers to the finished thing. There is no in between state a reader can catch.
The state machine
Every album is a small state machine and the states are path names. Each transition is either a rename on one machine or a verified copy between the two, so if it dies at any point the state it left behind is unambiguous on the next run.
# local remote
_Staging/<album>
| rsync --partial
v
_Inbox/.incoming-<album> in flight, never complete
| sha256 both ends
v
_Inbox/<album> verified, not yet visible
| rename(2), same device
v
AutoAdd/<album> iTunes may now see it
| local mv
v
_Sent/<album>
The dotted prefix does real work. A transfer that gets killed leaves .incoming-<album>, which is a name you can tell apart from a finished album just by looking at it. The next run resumes into it instead of guessing. Nothing else in the tree can be mistaken for a completed state.
Worth saying what it deliberately doesn't do. It never writes into the watch folder and renames inside it, because the write itself would be visible. The staging folder is a sibling on the same disk specifically so that the only thing iTunes can witness is the rename.
Checking both folders are on the same disk
mv quietly changes behaviour depending on where things are. Within one filesystem it calls rename(2). Across filesystems it falls back to copy then delete, which is exactly the visible partial write the whole thing exists to avoid. That failure is silent and it breaks correctness, so it gets checked rather than assumed.
INBOX_DEV=$(ssh "$HOST" "stat -f %d $(rq "$RHOME/Music/_Inbox")")
AUTOADD_DEV=$(ssh "$HOST" "stat -f %d $(rq "$AUTOADD")")
# per album, before moving any bytes:
if [ "$INBOX_DEV" != "$AUTOADD_DEV" ]; then
say " abort: _Inbox ($INBOX_DEV) and watch folder ($AUTOADD_DEV) are on"
say " different filesystems - the mv would not be atomic"
return 1
fi
The device ID gets read once per run rather than per album. If the library ever moves to an external drive the whole thing refuses to run instead of silently becoming unsafe.
The check that the destination doesn't exist and the move itself are two separate operations.
ssh "$HOST" "[ ! -e $(rq "$dest") ] && mv -- $(rq "$inbox_final") $(rq "$dest")"
In between those two, something else could create the destination, and then mv would move the source inside it rather than alongside. macOS actually has the right call for this, renamex_np with RENAME_EXCL, which fails atomically if the destination already exists. mv doesn't expose it, so using it would mean a small C helper or a Python ctypes call. The window is milliseconds and the only other writer is iTunes draining the folder, so I've left it. But it is a real race and not a solved one.
Quoting paths for the other machine
Both rsync versions hand the remote path to a shell on the far side. The new Mac ships openrsync, the old one ships rsync 2.6.9, and neither implements --protect-args, which was added in rsync 3.0. So there's no way to ask the protocol to pass a path through untouched. What that looks like:
$ rsync host:'/tmp/x/Test Album' dest/ rsync: link_stat "/tmp/x/Test" failed: No such file or directory (2) rsync: link_stat "$HOME/Album" failed: No such file or directory (2)
The far side split on the space and then resolved the leftover fragment against the home directory. Every real album name has a space in it, so this isn't an edge case, it happens every time. The fix is to send a path that survives one round of shell parsing at the other end.
# Quote a path so the REMOTE shell sees exactly one argument.
rq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
This is standard POSIX single quote wrapping. Wrap the whole thing in single quotes, and rewrite any embedded single quote as close, escaped literal, reopen. Single quotes suppress every other kind of expansion, so it covers everything: spaces, dollar signs, backticks, globs, newlines, emoji, leading dashes. Every remote path goes through it, including inside ssh command strings.
There's a second escaper for the AppleScript bridge, where the problem is different. That one is about breaking out of a string literal rather than word splitting.
as_esc() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; }
Backslashes first, then quotes. Doing it the other way round would double escape the backslashes that the second pass just added.
Verification
rsync already checksums its own transfers, so this layer is belt and braces. It's cheap though, and it covers the one thing rsync doesn't: something changing the destination after the transfer finished.
local_sums() {
(cd "$1" && LC_ALL=C find . -type f ! -name '.DS_Store' ! -name '._*' \
! -iname '*.jpg' ! -iname '*.jpeg' ! -iname '*.png' -print0 \
| LC_ALL=C sort -z | xargs -0 shasum -a 256)
}
Four things in there matter:
LC_ALL=Con bothfindandsort. Sort order depends on locale and the two machines run different macOS versions. Without pinning it the two sorted lists can come out in different orders and you get mismatches that aren't real.- Relative paths. The
cdis inside a subshell so each line reads./NN - Title.m4a, identical on both machines. Absolute paths would differ by home directory and never match. - The same exclusion list on both sides. The remote function repeats the identical predicate. If those ever drift, verification fails on a file that was never sent, and it looks exactly like corruption.
-print0,sort -zandxargs -0all the way through, so filenames never get re-split on whitespace.
The comparison is just diff on the two outputs, which catches both "this checksum is wrong" and "this file is missing" in one go. It's also why preflight refuses filenames containing a newline. The verification is line based, so a newline in a filename could hide a mismatch inside what looks like a valid line. The parser and the thing checking it have to agree on their assumptions.
Why not the built in tag reader
Preflight has to reject untagged files. The obvious tool is Spotlight's mdls.
$ mdls -name kMDItemAuthors -name kMDItemAlbum "01 - Track.m4a" kMDItemAlbum = (null) kMDItemAuthors = (null)
mdls reads the Spotlight index, not the file. A file written thirty seconds ago hasn't been indexed. Files in temp directories never get indexed. A volume can have indexing switched off entirely. So it returns null for a perfectly well tagged file, and the check would have thrown out every album.
So tags get read out of the container directly instead. 86 lines, standard library only.
CONTAINERS = {b'moov', b'udta', b'meta', b'ilst'}
WANT = {b'\xa9ART': 'artist', b'\xa9alb': 'album',
b'\xa9nam': 'title', b'trkn': 'track'}
def atoms(f, start, end):
"""Yield (kind, payload_start, payload_end) for atoms in [start, end)."""
pos = start
while pos + 8 <= end:
f.seek(pos)
hdr = f.read(8)
if len(hdr) < 8: return
size = struct.unpack('>I', hdr[:4])[0]
kind = hdr[4:8]
body = pos + 8
if size == 1: # 64-bit extended size
size = struct.unpack('>Q', f.read(8))[0]
body = pos + 16
elif size == 0: # runs to end of container
size = end - pos
if size < 8 or pos + size > end: return
yield kind, body, pos + size
pos += size
An MP4 is a tree of length prefixed boxes, each one [u32 size][4 char type][payload]. Tags live at moov then udta then meta then ilst then the tag atom then data.
Three details it has to get right:
metais a FullBox. It carries a four byte version and flags word before its children, unlike the containers around it. If you recurse at the wrong offset you land in the middle of an atom and the whole walk goes out of sync. This is the classic bug in hand written MP4 parsers.- A size of 1 means the real 64 bit length follows the header, so the payload starts eight bytes later than usual. A size of 0 means the atom runs to the end of its parent. Both are rare in audio files and both are handled.
trknis binary, not text. Two padding bytes, then the track number and the total as 16 bit integers. Decoding it as UTF-8 the way the text atoms are decoded gives you garbage instead of a number.
It seeks instead of reading, so the cost depends on the number of atoms and not the size of the file. A 25 MB track and a 250 MB side capture parse in the same time. Bounds get checked against the enclosing atom at every level, so a truncated or malformed file ends the walk rather than looping or reading past the end. Tested against a file carrying 210 KB of embedded cover art, which is the case that breaks parsers that assume tag atoms are small.
Preflight, and a scoping trap
The gate rejects anything that isn't a .m4a, anything afinfo doesn't report as ALAC, anything missing artist, album, title or track number, and any filename with a newline in it. Artwork files sitting next to the tracks get skipped rather than rejected, because iTunes uses the art embedded in the files and a loose jpg in the watch folder would just end up in "Not Added".
It also has to hand the album's artist and title back out for the duplicate check, and that only works because of how the loop gets its input.
while IFS= read -r -d '' f; do
...
[ -n "$ALBUM_ARTIST" ] || ALBUM_ARTIST="$artist"
done < <(LC_ALL=C find "$dir" -type f ... -print0 | LC_ALL=C sort -z)
That's process substitution, not a pipe. Writing it as find | while would run the loop body in a subshell and every assignment would be thrown away at the closing done. It's a well known bash trap and it fails silently. Redirecting from <(...) keeps the loop in the current shell so the variables survive.
The duplicate check
The other two collision checks only look at the pipeline's own state. Neither one knows what the library already has, which is how the first real run quietly made fifteen duplicates of an album that had been imported three weeks earlier under a different folder name. So this one asks iTunes directly.
library_count() {
artist=$(as_esc "$1"); album=$(as_esc "$2")
printf '%s\n' \
'with timeout of 120 seconds' \
'tell application "iTunes"' \
" return (count of (every file track of library playlist 1 whose album is \"$album\" and artist is \"$artist\")) as string" \
'end tell' \
'end timeout' \
| ssh -o BatchMode=yes "$HOST" 'osascript -'
}
The script gets built locally and piped over ssh into osascript - reading stdin, which avoids a whole second layer of shell quoting. The whose clause is an indexed query rather than a scan, so it comes back in about a second against 95,000 tracks. Looping in AppleScript instead would take minutes. The with timeout wrapper is there because a busy iTunes returns an AppleEvent error rather than just blocking.
If the query returns anything that isn't a number, because ssh failed or iTunes isn't scriptable at that moment, it warns and carries on.
case "$have" in
''|*[!0-9]*) say " warning: could not query iTunes; duplicate check skipped" ;;
0) ;;
*) say " abort: iTunes already holds $have track(s) ..." ; return 1 ;;
esac
Failing open like that means a temporary ssh problem silently switches off the guard. Failing closed would block real imports any time iTunes was busy. Since the failure mode here is duplicates, which are annoying but easy to spot and easy to undo, rather than losing data, open seemed like the right call. It's still the weakest part of the whole safety argument.
What happens when things fail
set -uo pipefail # note: no -e
-e is deliberately left off. Every operation that can fail gets checked explicitly, because the behaviour I wanted is lopsided. Stop hard inside one album, but keep going across albums. One bad album shouldn't block the queue, and a failed checksum should never fall through into the import.
for dir in "$STAGING"/*; do
[ -d "$dir" ] || continue
case "${dir##*/}" in .*) continue ;; esac
if send_album "$dir"; then ok=$((ok+1)); else failed=$((failed+1)); fi
done
say "--- $ok succeeded, $failed failed ---"
[ "$failed" -eq 0 ] || exit 1
Non-zero exit if anything failed, each album isolated, and every rejection logged with the specific reason. -u stays on to catch mistyped variable names and pipefail is there so a failure in the first half of a pipeline doesn't get hidden by a successful sort.
There's one asymmetry the script calls out rather than hiding. If the import works but the local move into _Sent fails, the album is in the library but still sitting in the staging folder. It says so explicitly and tells you to move it by hand, because otherwise the next run would be blocked by the duplicate check, which is correct but confusing without the message.
Two bugs that read fine
Both of these were written confidently, both looked correct on the page, and both got caught the first time the script ran against real input. Neither is findable without running it.
case "$base" in
*"$(printf '\n')"*) reject ;; # meant to catch a newline in a filename
esac
Command substitution strips every trailing newline, so $(printf '\n') is an empty string. The pattern becomes *""*, which is just *, which matches every filename there is. A check meant to catch a pathological edge case rejected all fifteen tracks of a perfectly good album.
The fix is ANSI-C quoting, *$'\n'*, which is a literal and doesn't get substituted.
My first attempt to test the fix had the same bug in the test. I built the test string as b="bad$(printf '\n')name", which also strips the newline, so the string contained no newline and the test passed for entirely the wrong reason. Confirmed it properly with b=$'bad\nname.m4a' and a length check.
while IFS= read -r -d '' f; do
afinfo "$f" # f is "./01 - Track.m4a"
done < <(cd "$dir" && find . -type f -print0 | sort -z)
The cd only applies inside the process substitution subshell. The loop body runs in whatever directory the caller was in, so every relative path resolved against the wrong base and afinfo reported Fail: AudioFileOpenURL failed for all fifteen files. From the error message alone that is indistinguishable from genuinely corrupt audio.
Fix is to use find "$dir" and get absolute paths. The checksum functions keep the cd, because they actually need relative paths so the two machines can be compared, and they can keep it because there the cd and the thing consuming it are inside the same subshell. Same construct, opposite requirement, and there's now a comment explaining it in both places.
Both of these live in the gap between what the code says and what the shell actually does, and neither one is visible without running it against real input. Which is the argument for having a dry run mode at all.
How it got tested
Against real data rather than fixtures, including a deliberately horrible album name combining a leading dash, an emoji, an ampersand, an apostrophe and a double space, in both the folder name and the filenames.
| Case | Method | Result |
|---|---|---|
| Happy path | 15 tracks, 290 MB | 7.0 s end to end |
| Integrity | independent digest of digests, both machines | identical |
| Interruption | killed rsync's ssh transport at ~40% of 1.4 GB | aborted, exit 1, nothing imported |
| Resume | re-ran against the 31 of 75 file partial | completed, 75 verified, 22 s |
| Idempotence | two runs back to back | no-op, exit 0 |
| Collision guards | seeded each of the three collision states | all aborted before transferring |
| Failover | pointed the fast alias at a dead address | fell back to wifi |
| The gate | non-m4a, non-audio, untagged files | each rejected with its own reason |
The interruption one is the test that matters. Killing the transport mid transfer left exactly the state it was designed to leave: the temporary folder holding 31 of 75 files, the watch folder untouched, the source still in staging. Re-running resumed via --partial, rechecked all 75 checksums and finished. That single test is what turns the resume behaviour from a hope into a claim.
There's also an environment variable that redirects the final rename to a scratch folder on the same disk, so the whole path can be exercised without anything landing in the real library. It prints a banner when it's active.
Knowing when an export has finished
launchd can watch a directory and run something when it changes, which is how the pipeline starts itself. The problem is that it fires on the first file of an export, not the last. Running the send immediately would ship a half exported album.
There's a nastier version of the same problem underneath. WatchPaths watches the directory node, so it fires when a file is created or removed. It does not fire while an existing file is still being written to. So counting files is useless: you'd see twelve tracks, decide the export was done, and copy a file that was still growing.
So once triggered, the watcher stops trusting launchd and polls for itself. It fingerprints the whole folder and waits for that fingerprint to stop changing:
fingerprint() {
find "$STAGING" -type f ! -name '.DS_Store' -exec stat -f '%N %z %m' {} + 2>/dev/null \
| LC_ALL=C sort | shasum -a 256 | awk '{print $1}'
}
Name, size and mtime for every file. Size is the part that matters: a track still being written changes its size on every flush, so the fingerprint keeps moving even though no new file has appeared and launchd has gone quiet. It waits for a full quiet period of no change before doing anything, with an hour long ceiling in case something is genuinely stuck.
Concurrency is handled by using mkdir as the lock, since it either creates the directory or fails, atomically. A stale lock older than two hours gets cleared, so a crashed run can't wedge it permanently.
Confirming iTunes actually imported it
The atomic move puts the album where iTunes will find it. That is not the same as iTunes having imported it, and the gap is bigger than you'd guess. Transferring and verifying a 290 MB album takes about 7 seconds. iTunes then takes roughly two more minutes to notice and ingest it.
So after the move it polls the library until the track count reaches what it sent:
await_import() {
local artist="$1" album="$2" want="$3" waited=0 have
while [ "$waited" -lt "$IMPORT_TIMEOUT" ]; do
have=$(library_count "$artist" "$album")
case "$have" in
''|*[!0-9]*) ;;
*) [ "$have" -ge "$want" ] && { printf '%s' "$have"; return 0; } ;;
esac
sleep 5
waited=$((waited + 5))
done
printf '%s' "${have:-0}"
return 1
}
Non-numeric answers are ignored rather than treated as zero, so a momentary AppleScript error doesn't abort the wait. On timeout it reports the real count it saw, still files the local copy so nothing is lost, and says to check iTunes is running. Then a notification gives the album and the elapsed time.
Deleting things safely
A daily job clears out the folder of already sent albums. Deletion is the one place in this whole system where being wrong is expensive, so it inverts the usual bias.
For each album it reads the artist and album out of the files, asks iTunes how many tracks it holds for that pair, and only removes the local copy when iTunes has at least as many tracks as are on disk. Files go to the Trash, never rm.
Note the asymmetry with the duplicate check described earlier. That one fails open: if it can't reach iTunes it proceeds, because the worst case is a duplicate. This one fails closed: unreachable machine, unreadable tags, missing artist, partial import all mean keep the files and exit successfully. A skipped cleanup costs disk space. A wrong one costs a recording. Same query, opposite default, and that's deliberate.
Reading sensors without root
powermetrics reports temperatures but demands root, which I didn't want a background monitor asking for. The SMC's user client, though, is readable by anyone. So there's a small C program on the old machine that talks to it directly through IOKit and prints JSON.
It opens the AppleSMC service, asks for a key's metadata, then reads its bytes, and decodes the two fixed point formats Apple uses. Temperatures are sp78, one signed integer byte plus a fractional byte. Fan speeds are fpe2, fourteen integer bits and two fractional:
// sp78: signed fixed point, 8 integer bits, 7 fractional. return (double)b[0] + (double)b[1] / 256.0; // fpe2: unsigned fixed point, 14 integer bits, 2 fractional. return (double)(((int)b[0] << 6) | ((int)b[1] >> 2));
Different Macs populate different sensor keys, so it tries TC0P, TC0D, TC0E and a couple of others and takes the first that returns something plausible, then reads FNum to find out how many fans exist before asking each one for its speed.
One compilation detail. It has to build on Mojave, and kIOMasterPortDefault was renamed kIOMainPortDefault in macOS 12. Since the old machine will never run anything newer, the source carries a small shim so it compiles either way:
#ifndef kIOMainPortDefault #define kIOMainPortDefault kIOMasterPortDefault #endif
The menu bar app
A SwiftUI MenuBarExtra, about 400 lines, deliberately thin. It contains no knowledge of the pipeline at all. A separate Python collector gathers everything into a single JSON blob, using one ssh round trip for all the remote data, and the app just decodes and draws it every sixty seconds.
That split means anything I want to monitor can be added without touching Swift, and the collector can be run by hand to see exactly what the app sees.
Two things caught me out. Menu bar symbols are template tinted, so colour doesn't survive: health has to be encoded in the shape of the icon, not its colour. And a GUI app inherits almost no environment, so the collector has to be handed an explicit PATH or every ssh, df and tmutil call fails.
It also appends a line to its own log on every refresh. That's what let me work out why the icon showed an error while the data underneath was fine: the first ssh after launch is cold enough to blow through a six second connect timeout, so it briefly reported the machine unreachable. It now retries once at ten seconds before believing it.
What I'd flag if someone reviewed it
- The race in the final move. Real, not mitigated, and only properly fixable by dropping to
renamex_npwithRENAME_EXCL. - The duplicate check fails open. An ssh blip silently disables the strongest protection the whole thing has.
- The step that finalises the name in the landing folder uses
[ -e final ] || mv tmp final. Iffinalappeared during the transfer, the rename gets skipped, the verified copy is orphaned, and the folder that was already there is what gets imported. Very unlikely with one writer, still wrong. - The device ID gets sampled once per run rather than per album. A volume swapped mid run would go unnoticed.
- Albums are processed one at a time. Fine at 104 MB/s, and the checksum pass on the old CPU is the actual bottleneck. It would parallelise cleanly.
- The log is append only with no rotation. Trivial, but it grows forever.
- There's a variable declared and never used. Dead.
- The watcher's quiet period is a fixed sixty seconds. If VinylStudio ever stalls longer than that mid export it would send a partial album. A smarter version would check that no process still holds the files open.
- The monitor polls on a timer whether or not anything has changed, which wakes an ssh connection every minute forever. Event driven would be tidier.