9 Commits
Author SHA1 Message Date
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
lyrathorpe 147c4c77c8 chore(release): v0.1.1 2026-08-24 10:41:58 +00: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
lyrathorpe 9984bedd02 Merge pull request 'perf: size the encoder pool to the CPUs the container may use' (#3) from perf/encode-concurrency into main
Build and publish container / build (push) Successful in 7m6s
Reviewed-on: #3
2026-08-24 11:35:03 +01:00
Emma ThorpeandClaude Opus 5 2b831daa1a perf: size the encoder pool to the CPUs the container may use
Build and publish container / build (pull_request) Successful in 11m13s
libmp3lame is single-threaded, so concurrency is one ffmpeg process per file
and the pool width is the whole of it. The width came from os.cpu_count(),
which reports the host's cores and ignores a container's cpus: allowance -- on
a 12-thread host limited to 4 CPUs that is threefold oversubscription, which
costs context switches and NAS responsiveness for no throughput.

The default now reads the cgroup v2 quota, falling back to process CPU
affinity and then to the host count. Each pass logs the number it chose.

Also stops probing every file for embedded cover art. The probe only changes
the command when a cover file sits beside the track, so ask only then; with no
cover file, -map 0:v:0? already carries embedded art if there is any. Worth
roughly 30 ms per track against about 4 s of encoding, so this is tidiness
rather than a speed-up. The per-directory cover lookup is cached alongside.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 16:50:53 +01:00
5 changed files with 107 additions and 14 deletions
+22 -3
View File
@@ -63,9 +63,14 @@ 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 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 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 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. Only the inherits the mode of a source file in a library this tool does not own.
group bits are touched; whether the mirror is world-readable stays with the
umask, as does the ownership. 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 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 mtimes are correct, so nothing else would revisit them — and they are not
@@ -94,6 +99,20 @@ music-mirror --source /music --mirror /music-mp3 --subdir "Artist/Album"
`--subdir` never prunes: a partial pass cannot tell an orphan from a file `--subdir` never prunes: a partial pass cannot tell an orphan from a file
outside its own scope. outside its own scope.
### Concurrency
LAME is single-threaded — ffmpeg reports `Threading capabilities: none` for
`libmp3lame` — so throughput comes entirely from running several encoders at
once, one process per file. `--jobs` defaults to the CPUs the process may
actually use, which inside a container means the `cpus:` allowance rather than
the host's core count. Each pass logs the number it settled on.
As a rough guide, a Zen 3 core encodes about 4060× realtime at V0 depending on
clock, so six cores clear roughly 250 hours of audio per hour of wall clock.
The first full pass is the expensive one; after that only new and changed files
are touched. Lower `MUSIC_MIRROR_JOBS` if you would rather the NAS stayed
responsive than finished sooner.
Requires `ffmpeg` and `ffprobe` on `PATH`. The container image provides both. Requires `ffmpeg` and `ffprobe` on `PATH`. The container image provides both.
## Running it on TrueNAS Scale ## Running it on TrueNAS Scale
+48 -10
View File
@@ -16,6 +16,7 @@ makes runs idempotent without a database to keep in step.
import argparse import argparse
import concurrent.futures import concurrent.futures
import fcntl import fcntl
import functools
import logging import logging
import os import os
import re import re
@@ -83,7 +84,12 @@ MTIME_TOLERANCE_SECONDS = 2
# that may be tighter still. Directories need the execute bit too, or the group # that may be tighter still. Directories need the execute bit too, or the group
# cannot enter them to reach the readable files inside. # cannot enter them to reach the readable files inside.
GROUP_READ = 0o040 GROUP_READ = 0o040
GROUP_ENTER = 0o050
# 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
@@ -139,8 +145,13 @@ def make_group_readable(path):
path.chmod(mode | GROUP_READ) path.chmod(mode | GROUP_READ)
@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.
Cached because an album's tracks all ask the same question, and the answer
costs one stat per candidate name.
"""
for name in COVER_NAMES: for name in COVER_NAMES:
candidate = directory / name candidate = directory / name
if candidate.is_file(): if candidate.is_file():
@@ -217,7 +228,12 @@ def encode(source, mirror, quality_args, dry_run):
return Result("encoded", mirror) return Result("encoded", mirror)
mirror.parent.mkdir(parents=True, exist_ok=True) mirror.parent.mkdir(parents=True, exist_ok=True)
cover = None if has_embedded_picture(source) else find_cover(source.parent) # Probing costs an ffprobe process per file, so only ask when the answer
# can change the command. With no cover file beside the track, `-map
# 0:v:0?` carries embedded art if there is any and shrugs if there is not.
cover = find_cover(source.parent)
if cover is not None and has_embedded_picture(source):
cover = None
# Read the source's mtime before encoding, not after. If the file is still # Read the source's mtime before encoding, not after. If the file is still
# being written -- a Lidarr import landing mid-pass -- stamping the mirror # being written -- a Lidarr import landing mid-pass -- stamping the mirror
@@ -373,6 +389,7 @@ def run_once(scan_root, source_root, mirror_root, quality_args, jobs, dry_run, d
computed against; they differ only for a partial pass over one directory. computed against; they differ only for a partial pass over one directory.
""" """
started = time.monotonic() started = time.monotonic()
logger.info("pass starting with %d concurrent encoders", jobs)
counts = {"encoded": 0, "copied": 0, "skipped": 0, "failed": 0} counts = {"encoded": 0, "copied": 0, "skipped": 0, "failed": 0}
failures = [] failures = []
@@ -418,6 +435,25 @@ def acquire_lock(mirror_root):
return handle return handle
def default_jobs():
"""Return the number of CPUs this process may actually use.
os.cpu_count() reports the host's total, which in a container with a `cpus:`
limit means starting several times more encoders than there is CPU to run
them. libmp3lame is single-threaded, so one process per available CPU is the
whole of the concurrency story.
"""
try:
quota, period = Path("/sys/fs/cgroup/cpu.max").read_text().split()
if quota != "max":
return max(1, round(int(quota) / int(period)))
except (OSError, ValueError):
pass
if hasattr(os, "process_cpu_count"): # 3.13+, respects CPU affinity
return os.process_cpu_count() or 4
return os.cpu_count() or 4
def build_parser(): def build_parser():
"""Return the argument parser. Every option also reads an env var, so the """Return the argument parser. Every option also reads an env var, so the
container can be configured without a command line.""" container can be configured without a command line."""
@@ -443,8 +479,8 @@ def build_parser():
parser.add_argument( parser.add_argument(
"--jobs", "--jobs",
type=int, type=int,
default=int(os.getenv("MUSIC_MIRROR_JOBS", "0")) or (os.cpu_count() or 4), default=int(os.getenv("MUSIC_MIRROR_JOBS", "0")) or default_jobs(),
help="concurrent encodes (env MUSIC_MIRROR_JOBS; default: CPU count)", help="concurrent encodes (env MUSIC_MIRROR_JOBS; default: available CPUs)",
) )
parser.add_argument( parser.add_argument(
"--interval", "--interval",
@@ -474,12 +510,14 @@ 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 group # Directories are created with 0o777 masked by the umask, so clear the bits
# bits from it once here rather than chmod'ing every directory the walk # that matter from it once here rather than chmod'ing every directory the
# creates. Files cannot be handled this way -- mkstemp and copy2 both set a # walk creates. The `other` bits are left alone, since whether the mirror is
# mode outright -- so they get an explicit chmod instead. # 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) inherited = os.umask(0o077)
os.umask(inherited & ~GROUP_ENTER) 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")
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "music-mirror" name = "music-mirror"
version = "0.1.0" 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"
+15
View File
@@ -27,6 +27,21 @@ def tight_umask():
os.umask(previous) 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."""
+21
View File
@@ -222,6 +222,27 @@ def test_mirror_directories_are_group_traversable(tmp_path, make_flac, tight_uma
assert mode & stat.S_IXGRP, 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): 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 """A mirror written by an older version has a correct mtime, so nothing
else in the pass would revisit it.""" else in the pass would revisit it."""