soulsync/tests/test_art_apply.py
BoulderBadgeDad 3c758635d5 Cover-art filler: name the real cure when the music mount is read-only
Tim (Discord): cover-art automation fails with '[Errno 30] Read-only file
system' on every file; he chmod 777'd and nothing changed — because EROFS is
the KERNEL refusing writes to a docker ':ro' volume mount, which no chmod
can fix. SoulSync's response was a wall of per-file warnings and a fix
result that still said success with a soft "(read-only?)" hint.

- apply_art_to_album_files now pre-flights the album folder with statvfs
  (asks the kernel, writes nothing): a read-only mount short-circuits the
  whole album instead of failing file by file. Belt: a per-file/cover EROFS
  (overlay quirks where statvfs lies) still sets the flag.
- the repair worker's apply now FAILS the finding with the actual cure:
  "remove ':ro' from the volume mapping and recreate the container — chmod
  cannot change this". EACCES (a real permissions problem chmod CAN fix)
  deliberately keeps the old soft path.

Tests: RO mount short-circuits before any file/cover write, save-time EROFS
still flagged, EACCES not conflated with EROFS. 29 art/repair tests pass.
2026-06-07 10:33:33 -07:00

205 lines
8.2 KiB
Python

"""Tests for core.metadata.art_apply — on-disk art detection + applying art."""
import sys
import types
from types import SimpleNamespace
# Stubs so core.metadata.artwork (pulled in transitively) imports without the
# real Spotify / config dependencies (mirrors test_missing_cover_art.py).
if 'spotipy' not in sys.modules:
spotipy = types.ModuleType('spotipy')
oauth2 = types.ModuleType('spotipy.oauth2')
spotipy.Spotify = type('S', (), {})
oauth2.SpotifyOAuth = oauth2.SpotifyClientCredentials = type('O', (), {})
spotipy.oauth2 = oauth2
sys.modules['spotipy'] = spotipy
sys.modules['spotipy.oauth2'] = oauth2
if 'config.settings' not in sys.modules:
config_mod = types.ModuleType('config')
settings_mod = types.ModuleType('config.settings')
class _Cfg:
def get(self, key, default=None):
return default
def get_active_media_server(self):
return 'plex'
settings_mod.config_manager = _Cfg()
config_mod.settings = settings_mod
sys.modules['config'] = config_mod
sys.modules['config.settings'] = settings_mod
from core.metadata import art_apply as aa
# ── sidecar detection ──
def test_folder_has_cover_sidecar(tmp_path):
assert aa.folder_has_cover_sidecar(str(tmp_path)) is False
(tmp_path / 'cover.jpg').write_bytes(b'x')
assert aa.folder_has_cover_sidecar(str(tmp_path)) is True
def test_album_has_art_on_disk_no_local_file_is_true():
# No representative file (e.g. media-server-only album) → not flagged.
assert aa.album_has_art_on_disk('') is True
assert aa.album_has_art_on_disk(None) is True
def test_album_has_art_on_disk_sidecar_short_circuits(tmp_path, monkeypatch):
(tmp_path / 'cover.jpg').write_bytes(b'x')
track = tmp_path / '01 song.flac'
track.write_bytes(b'')
# Sidecar present → True without ever opening the audio file.
called = {'n': 0}
monkeypatch.setattr(aa, 'file_has_embedded_art', lambda p: called.__setitem__('n', called['n'] + 1) or False)
assert aa.album_has_art_on_disk(str(track)) is True
assert called['n'] == 0
def test_album_has_art_on_disk_no_sidecar_checks_file(tmp_path, monkeypatch):
track = tmp_path / '01 song.flac'
track.write_bytes(b'')
monkeypatch.setattr(aa, 'file_has_embedded_art', lambda p: False)
assert aa.album_has_art_on_disk(str(track)) is False
monkeypatch.setattr(aa, 'file_has_embedded_art', lambda p: True)
assert aa.album_has_art_on_disk(str(track)) is True
# ── embedded-art detection ──
def _fake_symbols(audio):
return SimpleNamespace(
File=lambda path: audio,
ID3=type('ID3', (), {}),
MP4=type('MP4', (), {}),
FLAC=type('FLAC', (), {}),
)
def test_file_has_embedded_art_flac_picture(tmp_path, monkeypatch):
f = tmp_path / 'a.flac'
f.write_bytes(b'')
audio = SimpleNamespace(pictures=['pic'], tags=None)
monkeypatch.setattr(aa, 'get_mutagen_symbols', lambda: _fake_symbols(audio))
assert aa.file_has_embedded_art(str(f)) is True
def test_file_has_embedded_art_none(tmp_path, monkeypatch):
f = tmp_path / 'a.flac'
f.write_bytes(b'')
audio = SimpleNamespace(pictures=[], tags=None)
monkeypatch.setattr(aa, 'get_mutagen_symbols', lambda: _fake_symbols(audio))
assert aa.file_has_embedded_art(str(f)) is False
# ── applying art ──
def test_apply_embeds_into_each_file_and_writes_cover(tmp_path, monkeypatch):
f1 = tmp_path / '01.flac'; f1.write_bytes(b'')
f2 = tmp_path / '02.flac'; f2.write_bytes(b'')
saved = []
audio = SimpleNamespace(tags=None, add_tags=lambda: None, save=lambda: saved.append(True))
monkeypatch.setattr(aa, 'get_mutagen_symbols', lambda: _fake_symbols(audio))
embed_calls = []
monkeypatch.setattr(aa, 'embed_album_art_metadata', lambda a, m: embed_calls.append(m) or True)
# download_cover_art is the standard cover.jpg writer — stub it to drop one.
monkeypatch.setattr(aa, 'download_cover_art',
lambda album_info, folder, ctx=None: open(f"{folder}/cover.jpg", 'wb').close())
meta = {'artist': 'A', 'album': 'B', 'album_art_url': 'http://x/y.jpg'}
res = aa.apply_art_to_album_files([str(f1), str(f2)], meta, {'album_name': 'B'}, folder=str(tmp_path))
assert res['embedded'] == 2
assert len(saved) == 2 # each file saved after embed
assert res['cover_written'] is True
def test_apply_skips_files_that_already_have_art(tmp_path, monkeypatch):
"""Purely additive: a file that already has art is left untouched (no
duplicate FLAC picture, no re-embed)."""
f1 = tmp_path / '01.flac'; f1.write_bytes(b'')
audio = SimpleNamespace(pictures=['existing'], tags=None, save=lambda: None)
monkeypatch.setattr(aa, 'get_mutagen_symbols', lambda: _fake_symbols(audio))
embed_calls = []
monkeypatch.setattr(aa, 'embed_album_art_metadata', lambda a, m: embed_calls.append(m) or True)
monkeypatch.setattr(aa, 'download_cover_art', lambda *a, **k: None)
res = aa.apply_art_to_album_files([str(f1)], {'album': 'B'}, {'album_name': 'B'}, folder=str(tmp_path))
assert res['embedded'] == 0
assert res['skipped'] == 1
assert embed_calls == [] # never re-embedded → no duplicate picture
def test_apply_counts_failures_without_raising(tmp_path, monkeypatch):
f1 = tmp_path / '01.flac'; f1.write_bytes(b'')
audio = SimpleNamespace(tags=object(), save=lambda: (_ for _ in ()).throw(OSError('read-only')))
monkeypatch.setattr(aa, 'get_mutagen_symbols', lambda: _fake_symbols(audio))
monkeypatch.setattr(aa, 'embed_album_art_metadata', lambda a, m: True)
monkeypatch.setattr(aa, 'download_cover_art', lambda *a, **k: None)
res = aa.apply_art_to_album_files([str(f1)], {'album': 'B'}, {'album_name': 'B'}, folder=str(tmp_path))
assert res['embedded'] == 0
assert res['failed'] == 1 # save() raised (read-only) — counted, not crashed
# ── read-only filesystem detection (Tim: docker ':ro' mount, Errno 30) ──
def test_apply_short_circuits_on_read_only_mount(tmp_path, monkeypatch):
"""statvfs says the mount is RO -> bail before touching any file, flag
read_only_fs so the caller can name the actual cure (remove ':ro')."""
f = tmp_path / 'a.mp3'
f.write_bytes(b'')
audio = SimpleNamespace(pictures=[], tags=None)
monkeypatch.setattr(aa, 'get_mutagen_symbols', lambda: _fake_symbols(audio))
monkeypatch.setattr(aa.os, 'statvfs',
lambda p: SimpleNamespace(f_flag=aa.os.ST_RDONLY))
called = []
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))
assert res['read_only_fs'] is True
assert res['embedded'] == 0 and res['failed'] == 1
assert called == [] # cover.jpg write never attempted
def test_apply_flags_erofs_from_save(tmp_path, monkeypatch):
"""statvfs passes (overlay quirk) but the save itself raises EROFS ->
per-file detection still sets read_only_fs."""
import errno as _errno
f = tmp_path / 'a.mp3'
f.write_bytes(b'')
def _boom():
raise OSError(_errno.EROFS, 'Read-only file system')
audio = SimpleNamespace(pictures=[], tags={'ok': 1}, save=_boom)
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)
res = aa.apply_art_to_album_files([str(f)], {}, {}, folder=str(tmp_path))
assert res['read_only_fs'] is True
assert res['failed'] == 1
def test_apply_normal_failure_not_flagged_read_only(tmp_path, monkeypatch):
def _boom():
raise PermissionError(13, 'Permission denied')
f = tmp_path / 'a.mp3'
f.write_bytes(b'')
audio = SimpleNamespace(pictures=[], tags={'ok': 1}, save=_boom)
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)
res = aa.apply_art_to_album_files([str(f)], {}, {}, folder=str(tmp_path))
assert res['read_only_fs'] is False # EACCES is chmod-able, EROFS is not
assert res['failed'] == 1