diff --git a/core/navidrome_client.py b/core/navidrome_client.py index b50582af..7ec9d286 100644 --- a/core/navidrome_client.py +++ b/core/navidrome_client.py @@ -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 [] \ No newline at end of file + return [] diff --git a/database/music_database.py b/database/music_database.py index e84c21e8..ea8eb98f 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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 diff --git a/tests/database/test_album_thumb_preservation.py b/tests/database/test_album_thumb_preservation.py new file mode 100644 index 00000000..79304239 --- /dev/null +++ b/tests/database/test_album_thumb_preservation.py @@ -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" diff --git a/tests/media_server/test_navidrome_pinning.py b/tests/media_server/test_navidrome_pinning.py index 500d3953..7850743f 100644 --- a/tests/media_server/test_navidrome_pinning.py +++ b/tests/media_server/test_navidrome_pinning.py @@ -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'