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>
119 lines
5.5 KiB
Markdown
119 lines
5.5 KiB
Markdown
# 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
|
||
|
||
```sh
|
||
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 `V0`–`V9`, 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:
|
||
|
||
```sh
|
||
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
|
||
|
||
```sh
|
||
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
|
||
|
||
```sh
|
||
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.
|