Cover art: detect read-only from the actual write, not statvfs (Sokhi false-positive)

Sokhi got a read-only error from the cover-art filler with NO ':ro' in his
compose. Root cause: my earlier Tim fix added a statvfs pre-flight that bailed
when f_flag & ST_RDONLY — but union/FUSE/network filesystems (mergerfs,
rclone, NFS), ubiquitous in self-hosted setups, misreport those mount flags.
A perfectly writable library could be flagged read-only and blocked. statvfs
is a guess; the only honest test is whether an actual write raises EROFS.

- Removed the statvfs pre-flight entirely. Read-only is now detected solely
  from a real EROFS on the embed write, which also fast-fails the remaining
  files (so no statvfs needed for the fast-fail Tim wanted either).
- Broadened the user message: a genuine read-only mount isn't always ':ro' —
  could be a read-only host/NFS/SMB mount or a mergerfs read-only branch.

Tests: writable FS succeeds even when statvfs would claim read-only (the
regression), real-EROFS-on-write still flagged + bails the rest, EACCES still
not conflated with EROFS. Dropped the now-moot Windows-statvfs test (statvfs
is no longer referenced). 445 art/cover/repair tests pass.
This commit is contained in:
BoulderBadgeDad 2026-06-07 19:57:34 -07:00
parent ed38d60b18
commit 6c3e285a49
3 changed files with 46 additions and 65 deletions

View file

@ -113,8 +113,15 @@ def apply_art_to_album_files(
Returns counts; never raises (unwritable/read-only files are skipped). Returns counts; never raises (unwritable/read-only files are skipped).
``read_only_fs`` is True when the target filesystem itself rejects writes ``read_only_fs`` is True when the target filesystem itself rejects writes
(EROFS a docker ``:ro`` volume mount; chmod can't fix that) so callers (a real EROFS from an actual write a ':ro' volume, a read-only host/NFS/
can tell the user the actual cure instead of a generic failure. SMB mount, or a read-only underlying fs) so callers can tell the user the
real cause instead of a generic failure.
NOTE: read-only is detected from an ACTUAL write raising EROFS, never from
statvfs/mount flags union/FUSE/network filesystems (mergerfs, rclone,
NFS) common in self-hosted setups misreport those flags, which would
false-block a perfectly writable library (Sokhi: read-only error with no
':ro' in compose). The write itself is the only honest test.
""" """
result = {"embedded": 0, "failed": 0, "skipped": 0, "cover_written": False, result = {"embedded": 0, "failed": 0, "skipped": 0, "cover_written": False,
"read_only_fs": False} "read_only_fs": False}
@ -123,26 +130,6 @@ def apply_art_to_album_files(
if not symbols: if not symbols:
return result return result
# Pre-flight: if the mount is read-only, every save below would fail with
# EROFS one by one (Tim's report: a wall of per-file warnings and a 777
# chmod that couldn't help). statvfs asks the kernel without writing.
# POSIX-only — Windows has no os.statvfs (and no ':ro' bind mounts);
# there we skip straight to the per-file path, same as before this fix.
probe_dir = folder or (os.path.dirname(paths[0]) if paths else None)
_statvfs = getattr(os, "statvfs", None)
if probe_dir and _statvfs is not None:
try:
if _statvfs(probe_dir).f_flag & getattr(os, "ST_RDONLY", 1):
logger.warning(
"Art apply skipped: %s is on a READ-ONLY filesystem "
"(docker ':ro' volume mount — chmod cannot fix this)",
probe_dir)
result["read_only_fs"] = True
result["failed"] = len(paths)
return result
except OSError:
pass # statvfs unavailable/odd fs — fall through to per-file handling
for fp in paths: for fp in paths:
if not os.path.isfile(fp): if not os.path.isfile(fp):
result["skipped"] += 1 result["skipped"] += 1
@ -169,8 +156,13 @@ def apply_art_to_album_files(
result["failed"] += 1 result["failed"] += 1
except Exception as exc: except Exception as exc:
# Read-only mounts / permission errors land here — skip, don't crash. # Read-only mounts / permission errors land here — skip, don't crash.
# A real EROFS = the mount is read-only; flag it and stop trying the
# rest (fast-fail without the unreliable statvfs guess).
if getattr(exc, "errno", None) == errno.EROFS: if getattr(exc, "errno", None) == errno.EROFS:
result["read_only_fs"] = True result["read_only_fs"] = True
logger.warning("Could not embed art into %s: read-only filesystem", fp)
result["failed"] += len(paths) - paths.index(fp) # remaining all fail too
break
logger.warning("Could not embed art into %s: %s", fp, exc) logger.warning("Could not embed art into %s: %s", fp, exc)
result["failed"] += 1 result["failed"] += 1

View file

@ -1394,16 +1394,17 @@ class RepairWorker:
embedded = art_result.get('embedded', 0) embedded = art_result.get('embedded', 0)
if art_result.get('read_only_fs'): if art_result.get('read_only_fs'):
# Tim's report: a docker ':ro' volume — every write fails EROFS, # The music folder is genuinely read-only at the OS level (the
# the user chmod 777's in vain, and a soft "(read-only?)" hint # write raised EROFS). Most common cause is a docker ':ro' volume,
# wasn't loud enough. Fail the fix with the actual cure. # but it can also be a read-only host mount (NFS/SMB exported ro),
# a mergerfs/union read-only branch, or the library mounted from
# another container as read-only — chmod can't change any of these.
return {'success': False, 'action': 'applied_cover_art', return {'success': False, 'action': 'applied_cover_art',
'error': ('Your music folder is mounted READ-ONLY — the container ' 'error': ('Your music folder is READ-ONLY — the container cannot '
'cannot write to it, and chmod cannot change that. ' 'write to it (chmod cannot change this). Check that the '
"Remove ':ro' from the volume mapping in your docker " "volume isn't mapped ':ro', and that the underlying host "
"compose/run (e.g. '/music:/music:ro''/music:/music') " 'mount (NFS/SMB/mergerfs) is read-write, then recreate the '
'and recreate the container. (Database thumbnail was ' 'container. (Database thumbnail was still updated.)'),
'still updated.)'),
'art_result': art_result} 'art_result': art_result}
msg = f'Applied cover art: embedded into {embedded}/{len(resolved)} file(s)' msg = f'Applied cover art: embedded into {embedded}/{len(resolved)} file(s)'
if art_result.get('cover_written'): if art_result.get('cover_written'):

View file

@ -150,31 +150,36 @@ def test_apply_counts_failures_without_raising(tmp_path, monkeypatch):
# ── read-only filesystem detection (Tim: docker ':ro' mount, Errno 30) ── # ── read-only filesystem detection (Tim: docker ':ro' mount, Errno 30) ──
def test_apply_short_circuits_on_read_only_mount(tmp_path, monkeypatch): def test_apply_does_not_trust_statvfs_writable_fs_succeeds(tmp_path, monkeypatch):
"""statvfs says the mount is RO -> bail before touching any file, flag """Regression (Sokhi): a WRITABLE union/FUSE/NFS mount can misreport
read_only_fs so the caller can name the actual cure (remove ':ro').""" ST_RDONLY in statvfs. The apply must NOT use statvfs it writes anyway,
and only the actual write decides. Here the embed succeeds even though
statvfs (if it were consulted) would claim read-only."""
f = tmp_path / 'a.mp3' f = tmp_path / 'a.mp3'
f.write_bytes(b'') f.write_bytes(b'')
audio = SimpleNamespace(pictures=[], tags=None) saved = []
audio = SimpleNamespace(pictures=[], tags=None, add_tags=lambda: None,
save=lambda: saved.append(True))
monkeypatch.setattr(aa, 'get_mutagen_symbols', lambda: _fake_symbols(audio)) monkeypatch.setattr(aa, 'get_mutagen_symbols', lambda: _fake_symbols(audio))
monkeypatch.setattr(aa, 'embed_album_art_metadata', lambda *a, **k: True)
monkeypatch.setattr(aa, 'download_cover_art', lambda *a, **k: None)
# Even if statvfs lies and says read-only, the write still happens.
monkeypatch.setattr(aa.os, 'statvfs', monkeypatch.setattr(aa.os, 'statvfs',
lambda p: SimpleNamespace(f_flag=aa.os.ST_RDONLY)) lambda p: SimpleNamespace(f_flag=getattr(aa.os, 'ST_RDONLY', 1)),
called = [] raising=False)
monkeypatch.setattr(aa, 'download_cover_art', lambda *a, **k: called.append(a))
res = aa.apply_art_to_album_files([str(f)], {}, {}, folder=str(tmp_path)) res = aa.apply_art_to_album_files([str(f)], {}, {}, folder=str(tmp_path))
assert res['read_only_fs'] is True assert res['embedded'] == 1 and saved == [True]
assert res['embedded'] == 0 and res['failed'] == 1 assert res['read_only_fs'] is False # statvfs ignored; write succeeded
assert called == [] # cover.jpg write never attempted
def test_apply_flags_erofs_from_save(tmp_path, monkeypatch): def test_apply_flags_erofs_from_actual_write(tmp_path, monkeypatch):
"""statvfs passes (overlay quirk) but the save itself raises EROFS -> """read-only is detected from a real EROFS on write (the only honest test),
per-file detection still sets read_only_fs.""" and it fast-fails the remaining files."""
import errno as _errno import errno as _errno
f = tmp_path / 'a.mp3' f1 = tmp_path / 'a.mp3'; f1.write_bytes(b'')
f.write_bytes(b'') f2 = tmp_path / 'b.mp3'; f2.write_bytes(b'')
def _boom(): def _boom():
raise OSError(_errno.EROFS, 'Read-only file system') raise OSError(_errno.EROFS, 'Read-only file system')
@ -183,10 +188,10 @@ def test_apply_flags_erofs_from_save(tmp_path, monkeypatch):
monkeypatch.setattr(aa, 'embed_album_art_metadata', lambda *a, **k: True) monkeypatch.setattr(aa, 'embed_album_art_metadata', lambda *a, **k: True)
monkeypatch.setattr(aa, 'download_cover_art', lambda *a, **k: None) monkeypatch.setattr(aa, 'download_cover_art', lambda *a, **k: None)
res = aa.apply_art_to_album_files([str(f)], {}, {}, folder=str(tmp_path)) res = aa.apply_art_to_album_files([str(f1), str(f2)], {}, {}, folder=str(tmp_path))
assert res['read_only_fs'] is True assert res['read_only_fs'] is True
assert res['failed'] == 1 assert res['failed'] == 2 # first EROFS fails it + bails the rest
def test_apply_normal_failure_not_flagged_read_only(tmp_path, monkeypatch): def test_apply_normal_failure_not_flagged_read_only(tmp_path, monkeypatch):
@ -205,20 +210,3 @@ def test_apply_normal_failure_not_flagged_read_only(tmp_path, monkeypatch):
assert res['failed'] == 1 assert res['failed'] == 1
def test_apply_works_without_statvfs_windows(tmp_path, monkeypatch):
"""Windows has no os.statvfs — the pre-flight must vanish gracefully,
not crash the apply (Boulder runs on Windows)."""
f = tmp_path / 'a.mp3'
f.write_bytes(b'')
saved = []
audio = SimpleNamespace(pictures=[], tags=None, add_tags=lambda: None,
save=lambda: saved.append(True))
monkeypatch.setattr(aa, 'get_mutagen_symbols', lambda: _fake_symbols(audio))
monkeypatch.setattr(aa, 'embed_album_art_metadata', lambda *a, **k: True)
monkeypatch.setattr(aa, 'download_cover_art', lambda *a, **k: None)
monkeypatch.delattr(aa.os, 'statvfs', raising=False)
res = aa.apply_art_to_album_files([str(f)], {}, {}, folder=str(tmp_path))
assert res['embedded'] == 1 and saved == [True]
assert res['read_only_fs'] is False