Preserve Navidrome album cover art

Expose Navidrome album coverArt as a Subsonic getCoverArt thumbnail so library refreshes keep a real album-art URL. Preserve existing album thumb_url when an incoming server album has no thumbnail, preventing manual or server-corrected covers from being cleared and later replaced by loose missing-cover searches. Add regression tests for Navidrome album thumbnails and DB thumb preservation.
This commit is contained in:
Broque Thomas 2026-05-25 19:51:38 -07:00
parent dad1b5109e
commit 96e6ba0ed7
4 changed files with 124 additions and 4 deletions

View file

@ -67,6 +67,7 @@ class NavidromeAlbum:
self.year = navidrome_data.get('year')
self.addedAt = self._parse_date(navidrome_data.get('created'))
self._artist_id = navidrome_data.get('artistId', '')
self.thumb = self._get_album_image_url()
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
if not date_str:
@ -76,6 +77,18 @@ class NavidromeAlbum:
except:
return None
def _get_album_image_url(self) -> Optional[str]:
"""Generate a Subsonic getCoverArt URL for this album.
Navidrome exposes the stable artwork key as ``coverArt``. Falling
back to the album ID keeps compatibility with older responses while
ensuring library refreshes do not mark albums as artless.
"""
cover_id = self._data.get('coverArt') or self.ratingKey
if not cover_id:
return None
return f"/rest/getCoverArt?id={cover_id}"
def artist(self) -> Optional[NavidromeArtist]:
"""Get the album artist"""
if self._artist_id:
@ -1242,4 +1255,4 @@ class NavidromeClient(MediaServerClient):
except Exception as e:
logger.error(f"Error searching for tracks: {e}")
return []
return []

View file

@ -5247,7 +5247,7 @@ class MusicDatabase:
# Album exists - update it (update server_source if different)
cursor.execute("""
UPDATE albums
SET artist_id = ?, title = ?, year = ?, thumb_url = ?, genres = ?,
SET artist_id = ?, title = ?, year = ?, thumb_url = COALESCE(NULLIF(?, ''), thumb_url), genres = ?,
track_count = ?, duration = ?, server_source = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (artist_id, title, year, thumb_url, genres_json, track_count, duration, server_source, album_id))
@ -5280,6 +5280,7 @@ class MusicDatabase:
# Read enrichment data from old album
cursor.execute("SELECT * FROM albums WHERE id = ?", (old_id,))
old_row = cursor.fetchone()
preserved_thumb_url = thumb_url or (old_row['thumb_url'] if old_row and 'thumb_url' in old_row.keys() else None)
# Insert new album with fresh server metadata + preserved created_at
old_created = old_row['created_at'] if old_row else None
@ -5287,7 +5288,7 @@ class MusicDatabase:
INSERT INTO albums (id, artist_id, title, year, thumb_url, genres,
track_count, duration, server_source, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""", (album_id, artist_id, title, year, thumb_url, genres_json,
""", (album_id, artist_id, title, year, preserved_thumb_url, genres_json,
track_count, duration, server_source, old_created))
# Copy enrichment data from old record to new record

View file

@ -0,0 +1,87 @@
from __future__ import annotations
import sqlite3
from database.music_database import MusicDatabase
class _InMemoryDB(MusicDatabase):
def __init__(self):
self._conn = sqlite3.connect(":memory:")
self._conn.row_factory = sqlite3.Row
def _get_connection(self):
return _NonClosingConn(self._conn)
class _NonClosingConn:
def __init__(self, real):
self._real = real
def cursor(self):
return self._real.cursor()
def commit(self):
return self._real.commit()
def close(self):
pass
class _Album:
ratingKey = "album-1"
title = "Flower Boy"
year = 2017
leafCount = 15
duration = 2940
genres = []
thumb = None
def _seed(db):
cur = db._conn.cursor()
cur.execute("""
CREATE TABLE albums (
id TEXT PRIMARY KEY,
artist_id TEXT,
title TEXT,
year INTEGER,
thumb_url TEXT,
genres TEXT,
track_count INTEGER,
duration INTEGER,
server_source TEXT,
created_at TEXT,
updated_at TEXT
)
""")
cur.execute("""
INSERT INTO albums
(id, artist_id, title, year, thumb_url, server_source)
VALUES
('album-1', 'artist-1', 'Flower Boy', 2017, '/rest/getCoverArt?id=correct-cover', 'navidrome')
""")
db._conn.commit()
def test_album_refresh_preserves_existing_thumb_when_incoming_thumb_missing():
db = _InMemoryDB()
_seed(db)
assert db.insert_or_update_media_album(_Album(), "artist-1", server_source="navidrome") is True
row = db._conn.execute("SELECT thumb_url FROM albums WHERE id = 'album-1'").fetchone()
assert row["thumb_url"] == "/rest/getCoverArt?id=correct-cover"
def test_album_refresh_updates_existing_thumb_when_incoming_thumb_present():
db = _InMemoryDB()
_seed(db)
album = _Album()
album.thumb = "/rest/getCoverArt?id=new-cover"
assert db.insert_or_update_media_album(album, "artist-1", server_source="navidrome") is True
row = db._conn.execute("SELECT thumb_url FROM albums WHERE id = 'album-1'").fetchone()
assert row["thumb_url"] == "/rest/getCoverArt?id=new-cover"

View file

@ -12,7 +12,7 @@ from unittest.mock import MagicMock, patch
import pytest
from core.navidrome_client import NavidromeClient
from core.navidrome_client import NavidromeAlbum, NavidromeClient
@pytest.fixture
@ -77,3 +77,22 @@ def test_get_all_album_ids_returns_set(nav_client):
assert isinstance(result, set)
assert result == {'nav-1', 'nav-2'}
def test_navidrome_album_exposes_cover_art_url(nav_client):
album = NavidromeAlbum({
'id': 'album-1',
'name': 'Flower Boy',
'coverArt': 'cover-123',
}, nav_client)
assert album.thumb == '/rest/getCoverArt?id=cover-123'
def test_navidrome_album_cover_art_falls_back_to_album_id(nav_client):
album = NavidromeAlbum({
'id': 'album-1',
'name': 'Flower Boy',
}, nav_client)
assert album.thumb == '/rest/getCoverArt?id=album-1'