feat: mirror a lossless library to MP3 for iPod sync #1
@@ -1,12 +1,8 @@
|
||||
.git
|
||||
.gitignore
|
||||
tests
|
||||
result
|
||||
result-*
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
flake.nix
|
||||
flake.lock
|
||||
package.nix
|
||||
compose.yaml
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user