Emma ThorpeandClaude Opus 5 177761bb75 feat: mirror a lossless library to MP3 for iPod sync
Apple's Music app cannot read FLAC, so getting a lossless library onto an iPod
requires a converted copy somewhere. This keeps that copy beside the library on
a NAS rather than on a laptop, and keeps it current unattended.

Walks a source tree and reproduces it path for path as MP3: tags and cover art
carried across, already-MP3 sources copied rather than re-encoded, and mirror
files whose source has gone deleted along with any directories they emptied.
The source library is never written to.

Freshness is tracked by modification time -- an encoded file is stamped with
its source's mtime, so a file is stale exactly when the two differ. That keeps
runs idempotent without a database that could fall out of step with whatever
owns the library, which here is Lidarr.

Encodes go to a temporary file and are renamed into place, so an interrupted
run cannot leave a truncated MP3 that the next run mistakes for finished work.
A lock file in the mirror root prevents overlapping passes.

Ships as a Python package with a console script, a container image with a
TrueNAS Scale compose file, and a Nix flake providing the package, an overlay
and a dev shell. The test suite runs real ffmpeg encodes rather than mocks --
the failures worth catching are in what ffmpeg does with tags, cover art and
container formats.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 14:32:28 +01:00

music-mirror

Maintain a lossy MP3 mirror of a lossless music library.

Walks a source library and reproduces it, path for path, as MP3 in a separate tree. Tags and cover art are carried across; sources that are already MP3 are copied rather than re-encoded; mirror files whose source has been deleted are removed. The source library is never written to — it is mounted read-only in the supplied compose file, and nothing in the code opens it for writing.

The intended use is an iPod. Apple's Music app cannot read FLAC at all, so a converted copy has to exist somewhere; this keeps that copy next to the library on a NAS instead of on a laptop, and keeps it current without a human remembering to do anything.

How it decides what to do

Situation Action
No mirror file encode
Source modified since the mirror re-encode (a Lidarr quality upgrade)
Mirror up to date skip
Source is already MP3 copy verbatim
Source gone delete the mirror file, prune empty dirs

Freshness is modification time: an encoded file is stamped with its source's mtime, so a file is stale exactly when the two differ. There is no database to fall out of step with the library, which matters when something else — Lidarr, in this case — is the thing that owns and reorganises it.

Encodes are written to a temporary file and renamed into place, so an interrupted run cannot leave a truncated MP3 that the next run mistakes for finished work. A lock file in the mirror root stops two passes overlapping.

Usage

music-mirror --source /music --mirror /music-mp3            # one pass
music-mirror --source /music --mirror /music-mp3 --interval 6h   # keep running
music-mirror --source /music --mirror /music-mp3 --dry-run  # report only
music-mirror --source /music --mirror /music-mp3 --subdir "Artist/Album"
Option Environment variable Default Meaning
--source MUSIC_MIRROR_SOURCE Root of the lossless library, read-only
--mirror MUSIC_MIRROR_MIRROR Root of the MP3 mirror
--quality MUSIC_MIRROR_QUALITY V0 LAME VBR level V0V9, or kbps e.g. 256
--jobs MUSIC_MIRROR_JOBS CPU count Concurrent encodes
--interval MUSIC_MIRROR_INTERVAL unset Repeat forever, e.g. 45m, 6h, 1d
--subdir unset Limit the pass to one directory; skips pruning
--no-prune off Keep mirror files whose source has gone
--dry-run off Report what would change, write nothing

--subdir never prunes: a partial pass cannot tell an orphan from a file outside its own scope.

Requires ffmpeg and ffprobe on PATH. The container and the Nix package both provide them.

Running it on TrueNAS Scale

compose.yaml is a Custom App definition. Build the image on the NAS, adjust the two host paths and the user: to match your pool, then add it as a custom app:

git clone https://code.emmathe.dev/lyrathorpe/music-mirror
cd music-mirror && docker build -t music-mirror:latest .

Point the mirror at its own dataset rather than a directory inside the music dataset — it is derived data, so it wants its own snapshot policy, its own quota, and its own SMB share. The tool refuses to run with a mirror inside the source tree.

New Lidarr imports are picked up on the next pass. With MUSIC_MIRROR_INTERVAL at 6h that is the worst case; run --subdir by hand if you want an album immediately.

Nix

nix run .#music-mirror -- --source ./flac --mirror ./mp3
nix build .#music-mirror   # the test suite runs as part of the build
nix develop                # python, pytest and ffmpeg

overlays.default provides pkgs.music-mirror.

Tests

pytest

The tests run real ffmpeg encodes rather than mocking them — the interesting failures are in what ffmpeg actually does with tags, cover art and container formats, and a mock cannot fail that way. They skip if ffmpeg is absent.

Getting the result onto an iPod

The mirror is just a directory of MP3s, so any client will do:

  • macOS. Add the mirror's SMB share to the Music app with Copy files to Music Media folder and Keep Media folder organised both off. The Mac then stores a library database and nothing else. Keep the share mounted at a stable path — if it is missing when Music opens, every track shows !.
  • Linux. Rhythmbox links libgpod and handles iPod sync. An iPod Video (5th generation) predates the models whose database has to be signed, so no firmware-hash trickery is needed.

Neither client transcodes at sync time; they copy finished MP3s.

Two device-side details worth knowing: the iPod reads cover art from the file's tags and ignores folder.jpg, which is why art is embedded here; and volume levelling on the device uses iTunes' Soundcheck tag, not ReplayGain, so ReplayGain tags in the source are not carried over as such.

S
Description
Maintain a lossy MP3 mirror of a lossless music library
Readme
385 KiB
Languages
Python 89.7%
Shell 9.4%
Dockerfile 0.9%