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>
This commit is contained in:
Emma Thorpe
2026-08-21 14:32:28 +01:00
co-authored by Claude Opus 5
parent 38f545ac32
commit 177761bb75
14 changed files with 1133 additions and 1 deletions
+51
View File
@@ -0,0 +1,51 @@
{
lib,
python3Packages,
makeWrapper,
ffmpeg,
}:
python3Packages.buildPythonApplication {
pname = "music-mirror";
inherit ((lib.importTOML ./pyproject.toml).project) version;
pyproject = true;
src = lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.unions [
./music_mirror.py
./pyproject.toml
./pytest.ini
./tests
];
};
build-system = [ python3Packages.setuptools ];
nativeBuildInputs = [ makeWrapper ];
# ffmpeg and ffprobe are called as subprocesses, so they belong on the
# wrapper's PATH rather than in the Python environment.
makeWrapperArgs = [
"--prefix"
"PATH"
":"
(lib.makeBinPath [ ffmpeg ])
];
nativeCheckInputs = [
python3Packages.pytestCheckHook
ffmpeg
];
meta = {
description = "Maintain a lossy MP3 mirror of a lossless music library";
longDescription = ''
Reproduces a lossless library, path for path, as MP3 in a separate tree:
tags and cover art carried across, sources that are already MP3 copied
rather than re-encoded, and mirror files whose source has gone deleted.
The source library is never written to.
'';
homepage = "https://code.emmathe.dev/lyrathorpe/music-mirror";
mainProgram = "music-mirror";
platforms = lib.platforms.unix;
};
}