The real reason Sokhi's cover-art fix "didn't work" and Boulder saw the same message on a WRITABLE Windows box: _fix_missing_cover_art reported "Updated database thumbnail, but could not write art to files (read-only?)" whenever embedded==0 AND cover_written==False — but the overwhelmingly common cause of that is every file ALREADY having embedded art (skipped, not failed). The message blamed read-only on a perfectly writable library, which sent us chasing a read-only ghost across three commits. Sokhi's /fix returns 200 (success), so he was never hitting the genuine EROFS path at all. Now the message is derived from the art_result breakdown: - embedded/cover written → "Applied cover art: …" - failed>0 (non-EROFS) → "… check file/folder permissions" - skipped>0 (already arted) → "Cover art already present on all N file(s)" - otherwise → "no file artwork was applied" Genuine read-only (EROFS) still hard-fails with the clear mount message. Tests: already-arted→present (not read-only), failed→permissions, EROFS→hard fail, embedded→success. 453 cover/repair/art tests pass.
190 lines
7 KiB
Python
190 lines
7 KiB
Python
"""Per-target cover-art apply (Pache711: 'select one or the other to fix').
|
|
|
|
A missing-cover-art finding now offers album art AND artist art as
|
|
independently applyable targets. _fix_missing_cover_art routes on _fix_action:
|
|
'album' (default), 'artist', or 'both'. Verified against a real SQLite DB so
|
|
the UPDATE statements are exercised.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import types
|
|
|
|
if "spotipy" not in sys.modules:
|
|
spotipy = types.ModuleType("spotipy")
|
|
spotipy.Spotify = type("S", (), {})
|
|
oauth2 = types.ModuleType("spotipy.oauth2")
|
|
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_pkg = 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_pkg.settings = settings_mod
|
|
sys.modules["config"] = config_pkg
|
|
sys.modules["config.settings"] = settings_mod
|
|
|
|
import sqlite3
|
|
|
|
import pytest
|
|
|
|
from core.repair_worker import RepairWorker
|
|
|
|
|
|
class _DB:
|
|
def __init__(self, path):
|
|
self.path = str(path)
|
|
conn = self._get_connection()
|
|
c = conn.cursor()
|
|
c.execute("CREATE TABLE artists (id TEXT PRIMARY KEY, name TEXT, thumb_url TEXT, updated_at TEXT)")
|
|
c.execute("CREATE TABLE albums (id TEXT PRIMARY KEY, title TEXT, artist_id TEXT, thumb_url TEXT, musicbrainz_release_id TEXT, updated_at TEXT)")
|
|
c.execute("CREATE TABLE tracks (id TEXT PRIMARY KEY, album_id TEXT, file_path TEXT)")
|
|
c.execute("INSERT INTO artists VALUES ('ar1', 'Forre Sterra', 'http://old/artist.jpg', NULL)")
|
|
c.execute("INSERT INTO albums VALUES ('al1', 'For You', 'ar1', NULL, NULL, NULL)")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
def _get_connection(self):
|
|
return sqlite3.connect(self.path)
|
|
|
|
|
|
def _worker(tmp_path):
|
|
w = RepairWorker.__new__(RepairWorker)
|
|
w.db = _DB(tmp_path / "m.db")
|
|
w.transfer_folder = str(tmp_path)
|
|
w._config_manager = None
|
|
return w
|
|
|
|
|
|
def _thumbs(w):
|
|
conn = w.db._get_connection()
|
|
c = conn.cursor()
|
|
alb = c.execute("SELECT thumb_url FROM albums WHERE id='al1'").fetchone()[0]
|
|
art = c.execute("SELECT thumb_url FROM artists WHERE id='ar1'").fetchone()[0]
|
|
conn.close()
|
|
return alb, art
|
|
|
|
|
|
DETAILS = {
|
|
'album_id': 'al1', 'album_title': 'For You', 'artist': 'Forre Sterra',
|
|
'found_artwork_url': 'http://new/album.jpg',
|
|
'found_artist_url': 'http://new/artist.jpg',
|
|
}
|
|
|
|
|
|
def test_artist_only_sets_artist_leaves_album(tmp_path):
|
|
w = _worker(tmp_path)
|
|
res = w._fix_missing_cover_art('album', 'al1', None, {**DETAILS, '_fix_action': 'artist'})
|
|
assert res['success'] and res['action'] == 'applied_artist_art'
|
|
album_thumb, artist_thumb = _thumbs(w)
|
|
assert artist_thumb == 'http://new/artist.jpg' # artist updated
|
|
assert album_thumb is None # album untouched
|
|
|
|
|
|
def test_album_only_sets_album_leaves_artist(tmp_path):
|
|
w = _worker(tmp_path)
|
|
res = w._fix_missing_cover_art('album', 'al1', None, {**DETAILS, '_fix_action': 'album'})
|
|
assert res['success']
|
|
album_thumb, artist_thumb = _thumbs(w)
|
|
assert album_thumb == 'http://new/album.jpg' # album updated
|
|
assert artist_thumb == 'http://old/artist.jpg' # artist left as-is
|
|
|
|
|
|
def test_default_action_is_album_only(tmp_path):
|
|
# No _fix_action → behaves exactly like the old "Apply Art" (album only).
|
|
w = _worker(tmp_path)
|
|
w._fix_missing_cover_art('album', 'al1', None, dict(DETAILS))
|
|
album_thumb, artist_thumb = _thumbs(w)
|
|
assert album_thumb == 'http://new/album.jpg'
|
|
assert artist_thumb == 'http://old/artist.jpg'
|
|
|
|
|
|
def test_both_sets_album_and_artist(tmp_path):
|
|
w = _worker(tmp_path)
|
|
res = w._fix_missing_cover_art('album', 'al1', None, {**DETAILS, '_fix_action': 'both'})
|
|
assert res['success']
|
|
album_thumb, artist_thumb = _thumbs(w)
|
|
assert album_thumb == 'http://new/album.jpg'
|
|
assert artist_thumb == 'http://new/artist.jpg'
|
|
assert 'artist image' in res['message']
|
|
|
|
|
|
def test_artist_action_without_found_artist_url_fails_cleanly(tmp_path):
|
|
w = _worker(tmp_path)
|
|
res = w._fix_missing_cover_art('album', 'al1', None,
|
|
{**DETAILS, 'found_artist_url': None, '_fix_action': 'artist'})
|
|
assert res['success'] is False
|
|
album_thumb, artist_thumb = _thumbs(w)
|
|
assert artist_thumb == 'http://old/artist.jpg' # nothing changed
|
|
|
|
|
|
# ── apply-result message accuracy (Sokhi/Boulder: "read-only?" on writable fs) ──
|
|
|
|
def _add_track(w, path):
|
|
conn = w.db._get_connection()
|
|
c = conn.cursor()
|
|
c.execute("INSERT INTO tracks VALUES ('t1', 'al1', ?)", (str(path),))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def _apply_returns(monkeypatch, **art_result):
|
|
import core.metadata.art_apply as aa
|
|
base = {'embedded': 0, 'failed': 0, 'skipped': 0, 'cover_written': False, 'read_only_fs': False}
|
|
base.update(art_result)
|
|
monkeypatch.setattr(aa, 'apply_art_to_album_files', lambda *a, **k: base)
|
|
|
|
|
|
def test_already_arted_reports_present_not_readonly(tmp_path, monkeypatch):
|
|
# The bug: all files already had art (skipped) → embedded 0, cover 0 → the
|
|
# old message cried "(read-only?)" on a perfectly writable library.
|
|
w = _worker(tmp_path)
|
|
f = tmp_path / 'song.mp3'; f.write_bytes(b'x')
|
|
_add_track(w, f)
|
|
_apply_returns(monkeypatch, skipped=1)
|
|
res = w._fix_missing_cover_art('album', 'al1', None, {**DETAILS, '_fix_action': 'album'})
|
|
assert res['success'] is True
|
|
assert 'already present' in res['message'].lower()
|
|
assert 'read-only' not in res['message'].lower()
|
|
|
|
|
|
def test_failed_writes_blame_permissions_not_readonly(tmp_path, monkeypatch):
|
|
w = _worker(tmp_path)
|
|
f = tmp_path / 'song.mp3'; f.write_bytes(b'x')
|
|
_add_track(w, f)
|
|
_apply_returns(monkeypatch, failed=1)
|
|
res = w._fix_missing_cover_art('album', 'al1', None, {**DETAILS, '_fix_action': 'album'})
|
|
assert res['success'] is True
|
|
assert 'permission' in res['message'].lower()
|
|
assert 'read-only' not in res['message'].lower()
|
|
|
|
|
|
def test_genuine_read_only_still_hard_fails(tmp_path, monkeypatch):
|
|
w = _worker(tmp_path)
|
|
f = tmp_path / 'song.mp3'; f.write_bytes(b'x')
|
|
_add_track(w, f)
|
|
_apply_returns(monkeypatch, read_only_fs=True)
|
|
res = w._fix_missing_cover_art('album', 'al1', None, {**DETAILS, '_fix_action': 'album'})
|
|
assert res['success'] is False
|
|
assert 'read-only' in res['error'].lower()
|
|
|
|
|
|
def test_embedded_success_message(tmp_path, monkeypatch):
|
|
w = _worker(tmp_path)
|
|
f = tmp_path / 'song.mp3'; f.write_bytes(b'x')
|
|
_add_track(w, f)
|
|
_apply_returns(monkeypatch, embedded=1)
|
|
res = w._fix_missing_cover_art('album', 'al1', None, {**DETAILS, '_fix_action': 'album'})
|
|
assert res['success'] is True and 'embedded into 1' in res['message']
|