8 Commits
Author SHA1 Message Date
lyrathorpe d5f4329f46 Merge pull request 'ci: build the image once instead of twice' (#5) from ci/one-build-not-two into main
Reviewed-on: #5
2026-08-24 17:32:01 +01:00
Emma Thorpe ef59e52bca ci: build the image once instead of twice
Build and publish container / build (pull_request) Successful in 4m4s
Runs take fifteen to eighteen minutes, and the log shows why: the image is
built twice, in full.

The test stage is built by the runner's docker daemon. The runtime stage was
then built by docker/build-push-action, which runs under a buildx builder that
setup-buildx-action creates in its own container with its own cache. The two
share nothing, so the second build spent seventy-six seconds booting buildkit
and then installed ffmpeg and the package all over again -- around a hundred
and ten seconds for the apk and another hundred for pip, neither of which
produced anything the first build had not already made. The comment above the
test step claimed those layers were shared, which is what made this look
reasonable.

buildx earns that overhead when producing several architectures. This produces
linux/amd64 only, by an explicit decision recorded in the workflow, so it earns
nothing here. Use plain docker build against the same daemon that ran the
tests, and push with docker push. The runtime stage is a strict prefix of the
test stage, so every layer is a cache hit: measured at 1.3 seconds locally.

Identical to the change made in music-curator, whose workflow this one was
copied from.
2026-08-24 17:29:54 +01:00
lyrathorpe 4f57629b37 chore(release): v0.1.2 2026-08-24 12:41:42 +00:00
lyrathorpe 0cda3fc6ea Merge pull request 'fix: group-readable mirror output, and atomic copies' (#4) from fix/group-readable-output into main
Build and publish container / build (push) Successful in 18m16s
Reviewed-on: #4
2026-08-24 13:23:36 +01:00
Emma Thorpe e9852e6c86 fix: keep the mirror readable under a umask that masks the owner's read bit
Build and publish container / build (pull_request) Successful in 15m16s
The umask handling added for group access cleared only the group bits and left
owner and other to the environment. A container whose umask carries 0400 then
produces mirror directories of mode 0300: writable and enterable, unreadable to
the very run that created them, and unreadable to anything serving the share.

Clear the owner read and execute bits from the umask as well. The `other` bits
stay where the environment puts them, because whether the mirror is
world-readable is a real policy question; being able to read a directory the
process itself just created is not.

Files were never exposed to this: mkstemp sets 0600 outright and copy2 takes
the source file's mode, both ignoring the umask.
2026-08-24 13:21:07 +01:00
Emma Thorpe a1382185a7 fix: copy through a temporary file so a cut-short copy is not kept
Build and publish container / build (pull_request) Successful in 6m57s
Copies of already-MP3 sources were written straight to their destination while
encodes went via a temporary file and a rename. A copy interrupted by a full
disk, a killed container or an I/O error therefore left a truncated MP3 in the
mirror -- and because shutil.copy2 reproduces the source's mtime along with its
bytes, staleness detection would read that fragment as up to date and never
replace it. The damage is silent and permanent until someone plays the track.

Give copy the same temporary-file-and-rename path encode already uses, so the
destination either has the whole file or has nothing.
2026-08-24 11:36:08 +01:00
Emma Thorpe 6e48d94b32 docs: describe how the mirror handles permissions
Explain why the group bits are set explicitly rather than left to the umask,
what is deliberately not touched, and that an existing mirror is repaired in
place rather than re-encoded.
2026-08-24 11:36:08 +01:00
Emma Thorpe 100671da99 fix: make everything written into the mirror group-readable
The mirror is written by one account and read by another -- an SMB share, or
whatever else serves it -- but nothing here produced a group-readable file.
Encodes go through `tempfile.mkstemp`, which creates 0600 regardless of the
umask and keeps that mode through the rename into place, so every encoded
track landed unreadable. Copies of existing MP3s inherit the mode of a source
file in a library this tool does not own, which may be no better.

Add the group-read bit explicitly: to the temporary file before it is renamed,
so a mirror file is never visible without it, and to a copy once it has landed.
Directories are handled by clearing the group bits from the process umask
rather than chmod'ing each one, since a file the group cannot reach is no more
useful than one it cannot read. Only the group bits are touched; the world bits
and ownership stay with the umask as before.

Mirror files written before this are repaired on the next pass. Their mtimes
are correct, so no other part of the pass would revisit them, and topping up
the mode costs a stat rather than a re-encode.
2026-08-24 11:36:08 +01:00
6 changed files with 258 additions and 25 deletions
+30 -19
View File
@@ -45,7 +45,8 @@ jobs:
# The suite runs inside the image, against the ffmpeg that ships, rather # The suite runs inside the image, against the ffmpeg that ships, rather
# than against whatever the runner happens to provide. A failing test # than against whatever the runner happens to provide. A failing test
# fails the build. Layers are shared with the push build below. # fails the build. The runtime stage below is built from the same daemon
# afterwards, so its layers are already in cache.
- name: Run the test suite inside the image - name: Run the test suite inside the image
run: docker build --target test -t music-mirror:test . run: docker build --target test -t music-mirror:test .
@@ -124,9 +125,6 @@ jobs:
echo "release=${release}" >> "$GITHUB_OUTPUT" echo "release=${release}" >> "$GITHUB_OUTPUT"
echo "Computed bump=${bump}, release=${release}, base=${base}" echo "Computed bump=${bump}, release=${release}, base=${base}"
- name: Set up Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
- name: Log in to the Gitea container registry - name: Log in to the Gitea container registry
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
@@ -135,21 +133,34 @@ jobs:
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.PACKAGES_TOKEN }} password: ${{ secrets.PACKAGES_TOKEN }}
- name: Build and push # Plain `docker build` rather than buildx. buildx boots its own buildkit
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 # in a container with a cache of its own, so it shared nothing with the
with: # test build above and rebuilt the image from the base up -- installing
context: . # ffmpeg and the package a second time, for nothing. It earns that cost
# Without this the last stage in the Dockerfile -- the test stage -- # when building for several platforms; this only ever targets the amd64
# would be what gets published. # NAS, so it does not.
target: runtime #
# The NAS is the only host this runs on. Building arm64 as well would # `--target runtime` is a strict prefix of the test stage, so every layer
# mean emulating it under QEMU for no consumer. # is already in the daemon's cache and this resolves in seconds.
platforms: linux/amd64 - name: Build the runtime image
push: ${{ github.event_name != 'pull_request' }} run: |
tags: ${{ steps.version.outputs.tags }} set -euo pipefail
labels: | tags=()
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} while IFS= read -r tag; do
org.opencontainers.image.revision=${{ github.sha }} [ -n "$tag" ] && tags+=(-t "$tag")
done <<< "${{ steps.version.outputs.tags }}"
docker build --target runtime \
--label "org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \
--label "org.opencontainers.image.revision=${GITHUB_SHA}" \
"${tags[@]}" .
- name: Push
if: github.event_name != 'pull_request'
run: |
set -euo pipefail
while IFS= read -r tag; do
[ -n "$tag" ] && docker push "$tag"
done <<< "${{ steps.version.outputs.tags }}"
# Record the release: write the computed version into pyproject.toml, then # Record the release: write the computed version into pyproject.toml, then
# commit and tag it, so the packaging metadata always matches the release # commit and tag it, so the packaging metadata always matches the release
+24 -3
View File
@@ -51,9 +51,30 @@ 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 written when the pass reaches it would otherwise be stamped with its final
mtime while holding truncated audio, and never be revisited. mtime while holding truncated audio, and never be revisited.
Encodes are written to a temporary file and renamed into place, so an Both encodes and copies are written to a temporary file and renamed into place,
interrupted run cannot leave a truncated MP3 that the next run mistakes for so an interrupted run cannot leave a truncated MP3 that the next run mistakes
finished work. A lock file in the mirror root stops two passes overlapping. for finished work. Copies need it as much as encodes do: the mtime comes across
with the bytes, so a half-written copy would look current for ever. A lock file
in the mirror root stops two passes overlapping.
### Permissions
Everything written into the mirror is made group-readable, and its directories
group-traversable, so the mirror can be read back by whatever serves it. Neither
writer does that unaided: the temporary file an encode renames into place is
created `0600` regardless of the umask, and a straight copy of an existing MP3
inherits the mode of a source file in a library this tool does not own.
Directories are handled by clearing the owner and group read/execute bits from
the process umask, once, at startup. Owner as well as group, because a umask
carrying `0400` produces directories of mode `0300` — writable and enterable,
unreadable to the very run that created them. The `other` bits are left where
the umask puts them: whether the mirror is world-readable is a genuine policy
question, and so is its ownership.
Mirror files written before this existed are topped up on the next pass. Their
mtimes are correct, so nothing else would revisit them — and they are not
re-encoded, only chmod'ed.
## Usage ## Usage
+58 -2
View File
@@ -77,6 +77,20 @@ MIRROR_SUFFIX = ".mp3"
# Filesystems disagree about mtime precision; SMB in particular rounds. # Filesystems disagree about mtime precision; SMB in particular rounds.
MTIME_TOLERANCE_SECONDS = 2 MTIME_TOLERANCE_SECONDS = 2
# The mirror exists to be read back by something else -- an SMB share, another
# account on the box -- so everything written into it has to be group-readable.
# Neither writer manages that unaided: tempfile.mkstemp forces 0600 whatever the
# umask, and shutil.copy2 carries the source file's mode across from a library
# that may be tighter still. Directories need the execute bit too, or the group
# cannot enter them to reach the readable files inside.
GROUP_READ = 0o040
# Cleared from the umask so directories this run creates can be listed and
# entered. Owner as well as group: a umask carrying 0400 -- which is unusual but
# not ours to assume away -- otherwise produces a mirror tree that not even the
# process that built it can read back.
DIRECTORY_ACCESS = 0o550
@dataclass @dataclass
class Result: class Result:
@@ -124,6 +138,13 @@ def is_current(source, mirror):
return abs(source.stat().st_mtime - mirror.stat().st_mtime) <= MTIME_TOLERANCE_SECONDS return abs(source.stat().st_mtime - mirror.stat().st_mtime) <= MTIME_TOLERANCE_SECONDS
def make_group_readable(path):
"""Add the group-read bit to a mirror file, leaving the rest of the mode alone."""
mode = path.stat().st_mode
if not mode & GROUP_READ:
path.chmod(mode | GROUP_READ)
@functools.lru_cache(maxsize=4096) @functools.lru_cache(maxsize=4096)
def find_cover(directory): def find_cover(directory):
"""Return an external cover image for a directory, if one is present. """Return an external cover image for a directory, if one is present.
@@ -234,6 +255,9 @@ def encode(source, mirror, quality_args, dry_run):
lines = completed.stderr.strip().splitlines() lines = completed.stderr.strip().splitlines()
return Result("failed", source, lines[-1] if lines else "ffmpeg failed") return Result("failed", source, lines[-1] if lines else "ffmpeg failed")
os.utime(temporary, (stat.st_atime, stat.st_mtime)) os.utime(temporary, (stat.st_atime, stat.st_mtime))
# Before the rename, so the file is never visible in the mirror without
# the bit.
make_group_readable(temporary)
os.replace(temporary, mirror) os.replace(temporary, mirror)
except Exception as error: # noqa: BLE001 - reported per file, run continues except Exception as error: # noqa: BLE001 - reported per file, run continues
return Result("failed", source, str(error)) return Result("failed", source, str(error))
@@ -245,16 +269,31 @@ def encode(source, mirror, quality_args, dry_run):
def copy(source, mirror, dry_run): def copy(source, mirror, dry_run):
"""Copy an already-MP3 source into the mirror.""" """Copy an already-MP3 source into the mirror, atomically."""
if dry_run: if dry_run:
logger.info("would copy %s", source) logger.info("would copy %s", source)
return Result("copied", mirror) return Result("copied", mirror)
mirror.parent.mkdir(parents=True, exist_ok=True) mirror.parent.mkdir(parents=True, exist_ok=True)
# Through a temporary file and a rename, for the same reason encodes go
# that way, and a sharper one: copy2 reproduces the source's mtime as well
# as its bytes, so a copy cut short by a full disk or a killed container
# would leave a truncated MP3 that every later pass reads as current.
handle, temporary = tempfile.mkstemp(dir=mirror.parent, suffix=".mp3.part")
os.close(handle)
temporary = Path(temporary)
try: try:
shutil.copy2(source, mirror) shutil.copy2(source, temporary)
# copy2 brings the source's mode with it, and the source library is not
# ours to have permissions opinions about.
make_group_readable(temporary)
os.replace(temporary, mirror)
except OSError as error: except OSError as error:
return Result("failed", source, str(error)) return Result("failed", source, str(error))
finally:
temporary.unlink(missing_ok=True)
logger.info("copied %s", source) logger.info("copied %s", source)
return Result("copied", mirror) return Result("copied", mirror)
@@ -263,6 +302,14 @@ def copy(source, mirror, dry_run):
def process(source, mirror, quality_args, dry_run): def process(source, mirror, quality_args, dry_run):
"""Bring one source file's mirror entry up to date.""" """Bring one source file's mirror entry up to date."""
if is_current(source, mirror): if is_current(source, mirror):
# A mirror written before this bit was set has a correct mtime, so
# nothing else in the pass would ever revisit it. Top it up here
# instead: one stat per file, and no chmod at all once it is right.
if not dry_run:
try:
make_group_readable(mirror)
except OSError as error:
return Result("failed", mirror, str(error))
return Result("skipped", mirror) return Result("skipped", mirror)
if source.suffix.lower() in COPY_EXTENSIONS: if source.suffix.lower() in COPY_EXTENSIONS:
return copy(source, mirror, dry_run) return copy(source, mirror, dry_run)
@@ -463,6 +510,15 @@ def main(argv=None):
logging.basicConfig(format="%(asctime)s %(levelname)s %(message)s", level=logging.INFO) logging.basicConfig(format="%(asctime)s %(levelname)s %(message)s", level=logging.INFO)
args = build_parser().parse_args(argv) args = build_parser().parse_args(argv)
# Directories are created with 0o777 masked by the umask, so clear the bits
# that matter from it once here rather than chmod'ing every directory the
# walk creates. The `other` bits are left alone, since whether the mirror is
# world-readable is a real policy question; owner and group access is not.
# Files cannot be handled this way -- mkstemp and copy2 both set a mode
# outright, ignoring the umask -- so they get an explicit chmod instead.
inherited = os.umask(0o077)
os.umask(inherited & ~DIRECTORY_ACCESS)
if not args.source or not args.mirror: if not args.source or not args.mirror:
logger.error("both --source and --mirror are required") logger.error("both --source and --mirror are required")
return 2 return 2
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "music-mirror" name = "music-mirror"
version = "0.1.1" version = "0.1.2"
description = "Maintain a lossy MP3 mirror of a lossless music library" description = "Maintain a lossy MP3 mirror of a lossless music library"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+23
View File
@@ -19,6 +19,29 @@ def require_ffmpeg():
pytest.skip(f"{tool} is not on PATH", allow_module_level=True) pytest.skip(f"{tool} is not on PATH", allow_module_level=True)
@pytest.fixture
def tight_umask():
"""Run a test under a umask that would otherwise make the mirror private."""
previous = os.umask(0o077)
yield
os.umask(previous)
@pytest.fixture
def owner_hostile_umask():
"""Return a callable applying a umask that masks off the owner's read bit.
Unusual, but it is what produces a mirror tree of mode 0300 -- writable and
enterable, unreadable to the very process that built it. Applied on demand
rather than for the whole test, because the source library is built by
something else entirely and the same umask would make the test's own
fixtures unreadable before the run under test even started.
"""
previous = os.umask(0o022)
yield lambda: os.umask(0o477)
os.umask(previous)
@pytest.fixture @pytest.fixture
def make_flac(): def make_flac():
"""Return a factory writing a short tagged FLAC file.""" """Return a factory writing a short tagged FLAC file."""
+122
View File
@@ -1,7 +1,9 @@
import os import os
import shutil import shutil
import stat
import subprocess import subprocess
import time import time
from pathlib import Path
import pytest import pytest
@@ -153,6 +155,126 @@ def test_existing_mp3_is_copied_not_re_encoded(tmp_path, make_flac):
assert (mirror / "b.mp3").read_bytes() == (source / "b.mp3").read_bytes() assert (mirror / "b.mp3").read_bytes() == (source / "b.mp3").read_bytes()
def test_interrupted_copy_leaves_nothing_behind(tmp_path, make_flac, monkeypatch):
"""copy2 reproduces the source mtime, so a truncated copy left in the mirror
would be read as current by every later pass."""
source = tmp_path / "src"
mirror = tmp_path / "dst"
flac = make_flac(source / "a.flac")
subprocess.run(
["ffmpeg", "-loglevel", "error", "-y", "-i", str(flac), str(source / "b.mp3")],
check=True,
capture_output=True,
)
flac.unlink()
def truncated(src, destination, **kwargs):
Path(destination).write_bytes(Path(src).read_bytes()[:64])
raise OSError("no space left on device")
monkeypatch.setattr(music_mirror.shutil, "copy2", truncated)
assert run(source, mirror) == 1
assert not (mirror / "b.mp3").exists()
assert list(mirror.rglob("*.part")) == []
def test_encoded_file_is_group_readable(tmp_path, make_flac, tight_umask):
"""mkstemp creates 0600 whatever the umask, so the bit has to be added."""
source = tmp_path / "src"
mirror = tmp_path / "dst"
make_flac(source / "a.flac")
run(source, mirror)
assert (mirror / "a.mp3").stat().st_mode & stat.S_IRGRP
def test_copied_file_is_group_readable(tmp_path, make_flac, tight_umask):
"""copy2 carries the source's mode across, and the source may be private."""
source = tmp_path / "src"
mirror = tmp_path / "dst"
flac = make_flac(source / "a.flac")
subprocess.run(
["ffmpeg", "-loglevel", "error", "-y", "-i", str(flac), str(source / "b.mp3")],
check=True,
capture_output=True,
)
flac.unlink()
(source / "b.mp3").chmod(0o600)
run(source, mirror)
assert (mirror / "b.mp3").stat().st_mode & stat.S_IRGRP
def test_mirror_directories_are_group_traversable(tmp_path, make_flac, tight_umask):
"""A readable file is unreachable if the group cannot enter its directory."""
source = tmp_path / "src"
mirror = tmp_path / "dst"
make_flac(source / "Artist" / "Album" / "a.flac")
run(source, mirror)
for directory in (mirror, mirror / "Artist", mirror / "Artist" / "Album"):
mode = directory.stat().st_mode
assert mode & stat.S_IRGRP, directory
assert mode & stat.S_IXGRP, directory
def test_mirror_directories_survive_an_owner_hostile_umask(
tmp_path, make_flac, owner_hostile_umask
):
"""A umask carrying 0400 otherwise builds a tree the run cannot read back."""
source = tmp_path / "src"
mirror = tmp_path / "dst"
make_flac(source / "Artist" / "Album" / "a.flac")
# Applied only now: the library already exists, and the umask under test is
# the one the container starts this run with.
owner_hostile_umask()
run(source, mirror)
for directory in (mirror, mirror / "Artist", mirror / "Artist" / "Album"):
mode = directory.stat().st_mode
assert mode & stat.S_IRUSR, directory
assert mode & stat.S_IXUSR, directory
assert mode & stat.S_IRGRP, directory
assert mode & stat.S_IXGRP, directory
def test_private_mirror_file_is_repaired_without_re_encoding(tmp_path, make_flac):
"""A mirror written by an older version has a correct mtime, so nothing
else in the pass would revisit it."""
source = tmp_path / "src"
mirror = tmp_path / "dst"
make_flac(source / "a.flac")
run(source, mirror)
output = mirror / "a.mp3"
output.chmod(output.stat().st_mode & ~stat.S_IRGRP)
before = output.stat().st_mtime_ns
run(source, mirror)
assert output.stat().st_mode & stat.S_IRGRP
assert output.stat().st_mtime_ns == before
def test_dry_run_does_not_change_permissions(tmp_path, make_flac):
source = tmp_path / "src"
mirror = tmp_path / "dst"
make_flac(source / "a.flac")
run(source, mirror)
output = mirror / "a.mp3"
output.chmod(output.stat().st_mode & ~stat.S_IRGRP)
run(source, mirror, "--dry-run")
assert not output.stat().st_mode & stat.S_IRGRP
def test_format_upgrade_replaces_rather_than_duplicating(tmp_path, make_flac): def test_format_upgrade_replaces_rather_than_duplicating(tmp_path, make_flac):
"""Lidarr replacing an MP3 with a FLAC must not leave two mirror files.""" """Lidarr replacing an MP3 with a FLAC must not leave two mirror files."""
source = tmp_path / "src" source = tmp_path / "src"