diff --git a/.dockerignore b/.dockerignore index 7dff806..edd1516 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,12 +1,8 @@ .git .gitignore -tests result result-* __pycache__ *.pyc .pytest_cache -flake.nix -flake.lock -package.nix compose.yaml diff --git a/.gitea/workflows/build-and-publish.yaml b/.gitea/workflows/build-and-publish.yaml index 057279f..c751672 100644 --- a/.gitea/workflows/build-and-publish.yaml +++ b/.gitea/workflows/build-and-publish.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 diff --git a/Dockerfile b/Dockerfile index 3dc3283..8f50791 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 1e33785..7c5fffe 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/flake.lock b/flake.lock deleted file mode 100644 index 841c839..0000000 --- a/flake.lock +++ /dev/null @@ -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 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index 9a4d27a..0000000 --- a/flake.nix +++ /dev/null @@ -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); - }; -} diff --git a/package.nix b/package.nix deleted file mode 100644 index 65b5d77..0000000 --- a/package.nix +++ /dev/null @@ -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; - }; -}