Files
music-mirror/README.md
T
Emma ThorpeandClaude Opus 5 2b831daa1a
Build and publish container / build (pull_request) Successful in 11m13s
perf: size the encoder pool to the CPUs the container may use
libmp3lame is single-threaded, so concurrency is one ffmpeg process per file
and the pool width is the whole of it. The width came from os.cpu_count(),
which reports the host's cores and ignores a container's cpus: allowance -- on
a 12-thread host limited to 4 CPUs that is threefold oversubscription, which
costs context switches and NAS responsiveness for no throughput.

The default now reads the cgroup v2 quota, falling back to process CPU
affinity and then to the host count. Each pass logs the number it chose.

Also stops probing every file for embedded cover art. The probe only changes
the command when a cover file sits beside the track, so ask only then; with no
cover file, -map 0:v:0? already carries embedded art if there is any. Worth
roughly 30 ms per track against about 4 s of encoding, so this is tidiness
rather than a speed-up. The per-directory cover lookup is cached alongside.

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

158 lines
8.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
### What that means for Lidarr
| Lidarr does this | The mirror does this |
| --------------------------------------- | -------------------------------------------------------------------------------- |
| Replaces a file with a better rip | Re-encodes in place. Same path in, same path out, so no duplicate |
| Upgrades MP3 to FLAC | Both map to the same `.mp3` mirror path, so the old one is overwritten |
| Renames a track, album or artist folder | Old path pruned, new path encoded. Correct, but it re-encodes rather than moving |
| Deletes an album or artist | Every orphaned mirror file is deleted and the emptied directories go too |
Pruning is driven by what the pass actually found, not by guessing source
filenames from mirror ones: a `.FLAC` source would not be found by a search for
`.flac`, and the mirror file would be deleted and rebuilt on alternate passes
for ever.
If two sources want the same mirror path — a `01 Song.flac` next to a leftover
`01 Song.mp3`, which is what an interrupted upgrade leaves — the better format
wins, ties break on path, and the loser is logged. Without that rule both
encode to the same destination and every pass finds one of them stale.
The mtime is read _before_ encoding rather than after. A file still being
written when the pass reaches it would otherwise be stamped with its final
mtime while holding truncated audio, and never be revisited.
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.
### Concurrency
LAME is single-threaded — ffmpeg reports `Threading capabilities: none` for
`libmp3lame` — so throughput comes entirely from running several encoders at
once, one process per file. `--jobs` defaults to the CPUs the process may
actually use, which inside a container means the `cpus:` allowance rather than
the host's core count. Each pass logs the number it settled on.
As a rough guide, a Zen 3 core encodes about 4060× realtime at V0 depending on
clock, so six cores clear roughly 250 hours of audio per hour of wall clock.
The first full pass is the expensive one; after that only new and changed files
are touched. Lower `MUSIC_MIRROR_JOBS` if you would rather the NAS stayed
responsive than finished sooner.
Requires `ffmpeg` and `ffprobe` on `PATH`. The container image provides both.
## Running it on TrueNAS Scale
`compose.yaml` is a Custom App definition. Adjust the two host paths and the
`user:` to match your pool, then add it as a custom app. The image is published
to this Gitea's registry on every release:
```
code.emmathe.dev/lyrathorpe/music-mirror:latest
```
Tags are `latest`, the full version, and the truncated `major.minor` and
`major` forms; builds that are not releases are published as `sha-<short>`.
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.
## Tests
```sh
docker build --target test . # what CI runs
pytest # needs ffmpeg and pytest on PATH
```
The suite runs 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 — which is also why CI runs the tests
_inside the image_, against the ffmpeg that ships, rather than against whatever
the build runner provides. The published image is the `runtime` stage and
carries neither the tests nor pytest.
Run them directly instead if you prefer; they skip when ffmpeg is absent. On a
Nix machine:
```sh
nix shell nixpkgs#python3Packages.pytest nixpkgs#ffmpeg -c pytest
```
## 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.