Build and publish container / build (pull_request) Canceled after 14m40s
Adopts the release scheme from legacy-email-proxy so both repositories behave the same way: the version is derived from conventional commits since the last v* tag, the image is pushed under the full version, the truncated major.minor and major forms, and latest, and non-release builds are published as sha-<short>. Multi-arch (amd64 for the NAS, arm64 so the same image runs on a Pi). Authentication uses the PACKAGES_SECRET repository secret. The release step also writes the computed version into pyproject.toml and commits it as chore(release) before tagging, so the packaging metadata cannot drift behind the release. It skips the commit when the file already carries that version, which would otherwise fail the job after the image had been pushed. compose.yaml and the README now reference the published image instead of instructing the NAS to build one locally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
121 lines
5.6 KiB
Markdown
121 lines
5.6 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. 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.
|
||
|
||
## 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.
|