feat: mirror a lossless library to MP3 for iPod sync #1

Merged
lyrathorpe merged 6 commits from feat/initial-implementation into main 2026-08-21 15:30:59 +01:00
7 changed files with 37 additions and 156 deletions
Showing only changes of commit cbcbf30228 - Show all commits
-4
View File
@@ -1,12 +1,8 @@
.git
.gitignore
tests
result
result-*
__pycache__
*.pyc
.pytest_cache
flake.nix
flake.lock
package.nix
compose.yaml
+9 -15
View File
@@ -43,20 +43,11 @@ jobs:
# from the conventional-commit messages since the last release.
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.13
# The test suite runs real encodes, so ffmpeg is a test dependency.
- name: Install ffmpeg
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y ffmpeg
- name: Install test dependencies
run: python -m pip install --upgrade pip && pip install pytest
- name: Run unit tests
run: python -m pytest
# The suite runs inside the image, against the ffmpeg that ships, rather
# than against whatever the runner happens to provide. A failing test
# fails the build. Layers are shared with the push build below.
- name: Run the test suite inside the image
run: docker build --target test -t music-mirror:test .
- name: Determine registry host
run: echo "REGISTRY=${GITHUB_SERVER_URL#*://}" >> "$GITHUB_ENV"
@@ -145,6 +136,9 @@ jobs:
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:
context: .
# Without this the last stage in the Dockerfile -- the test stage --
# would be what gets published.
target: runtime
# amd64 for the NAS, arm64 so the same image runs on a Pi.
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
@@ -165,7 +159,7 @@ jobs:
run: |
set -euo pipefail
python - "$VERSION" <<'PY'
python3 - "$VERSION" <<'PY'
import pathlib
import re
import sys
+13 -2
View File
@@ -1,4 +1,4 @@
FROM python:3.13-slim
FROM python:3.13-slim AS runtime
ENV PYTHONUNBUFFERED=1
@@ -10,8 +10,19 @@ RUN apt-get update \
WORKDIR /app
COPY pyproject.toml README.md ./
COPY music_mirror.py ./
RUN pip install --no-cache-dir .
RUN pip install --no-cache-dir . \
&& rm -rf build music_mirror.egg-info
# Runs as root by default so a bind-mounted dataset of any ownership is
# writable. Override with `user:` in compose to run as the dataset's owner.
ENTRYPOINT ["music-mirror"]
# Test stage: the suite runs against this image's own ffmpeg, which is the one
# that ships. Build it with `--target test`; a failing test fails the build.
# The published image is the `runtime` stage above and carries none of this.
FROM runtime AS test
RUN pip install --no-cache-dir pytest
COPY pytest.ini ./
COPY tests ./tests
RUN python -m pytest
+15 -15
View File
@@ -78,8 +78,7 @@ music-mirror --source /music --mirror /music-mp3 --subdir "Artist/Album"
`--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.
Requires `ffmpeg` and `ffprobe` on `PATH`. The container image provides both.
## Running it on TrueNAS Scale
@@ -103,25 +102,26 @@ 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
docker build --target test . # what CI runs
pytest # needs ffmpeg and pytest on PATH
```
The tests run real ffmpeg encodes rather than mocking them — the interesting
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. They skip if ffmpeg is absent.
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
Generated
-27
View File
@@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1787135253,
"narHash": "sha256-RD2kNWCG+Bjo6h+JVjWVNntZs2GtRoeY2xHjts/FNkA=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "ffb3c9b700e759be2ef13237c9d8f953b32a1e46",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
-42
View File
@@ -1,42 +0,0 @@
{
description = "Maintain a lossy MP3 mirror of a lossless music library";
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
outputs =
{ self, nixpkgs }:
let
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = fn: nixpkgs.lib.genAttrs systems (system: fn nixpkgs.legacyPackages.${system});
in
{
overlays.default = final: _prev: {
music-mirror = final.callPackage ./package.nix { };
};
packages = forAllSystems (pkgs: rec {
music-mirror = pkgs.callPackage ./package.nix { };
default = music-mirror;
});
# The package builds only if the test suite passes, so this covers both.
checks = forAllSystems (pkgs: { inherit (self.packages.${pkgs.system}) music-mirror; });
devShells = forAllSystems (pkgs: {
default = pkgs.mkShellNoCC {
packages = [
pkgs.python3
pkgs.python3Packages.pytest
pkgs.ffmpeg
];
};
});
formatter = forAllSystems (pkgs: pkgs.nixfmt-tree);
};
}
-51
View File
@@ -1,51 +0,0 @@
{
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;
};
}