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:
parent
dad1b5109e
commit
96e6ba0ed7
4 changed files with 124 additions and 4 deletions
|
|
@ -67,6 +67,7 @@ class NavidromeAlbum:
|
||||||
self.year = navidrome_data.get('year')
|
self.year = navidrome_data.get('year')
|
||||||
self.addedAt = self._parse_date(navidrome_data.get('created'))
|
self.addedAt = self._parse_date(navidrome_data.get('created'))
|
||||||
self._artist_id = navidrome_data.get('artistId', '')
|
self._artist_id = navidrome_data.get('artistId', '')
|
||||||
|
self.thumb = self._get_album_image_url()
|
||||||
|
|
||||||
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
|
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
|
||||||
if not date_str:
|
if not date_str:
|
||||||
|
|
@ -76,6 +77,18 @@ class NavidromeAlbum:
|
||||||
except:
|
except:
|
||||||
return None
|
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]:
|
def artist(self) -> Optional[NavidromeArtist]:
|
||||||
"""Get the album artist"""
|
"""Get the album artist"""
|
||||||
if self._artist_id:
|
if self._artist_id:
|
||||||
|
|
@ -1242,4 +1255,4 @@ class NavidromeClient(MediaServerClient):
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error searching for tracks: {e}")
|
logger.error(f"Error searching for tracks: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
|
||||||
|
|
@ -5247,7 +5247,7 @@ class MusicDatabase:
|
||||||
# Album exists - update it (update server_source if different)
|
# Album exists - update it (update server_source if different)
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
UPDATE albums
|
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
|
track_count = ?, duration = ?, server_source = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""", (artist_id, title, year, thumb_url, genres_json, track_count, duration, server_source, album_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
|
# Read enrichment data from old album
|
||||||
cursor.execute("SELECT * FROM albums WHERE id = ?", (old_id,))
|
cursor.execute("SELECT * FROM albums WHERE id = ?", (old_id,))
|
||||||
old_row = cursor.fetchone()
|
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
|
# Insert new album with fresh server metadata + preserved created_at
|
||||||
old_created = old_row['created_at'] if old_row else None
|
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,
|
INSERT INTO albums (id, artist_id, title, year, thumb_url, genres,
|
||||||
track_count, duration, server_source, created_at, updated_at)
|
track_count, duration, server_source, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
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))
|
track_count, duration, server_source, old_created))
|
||||||
|
|
||||||
# Copy enrichment data from old record to new record
|
# Copy enrichment data from old record to new record
|
||||||
|
|
|
||||||
87
tests/database/test_album_thumb_preservation.py
Normal file
87
tests/database/test_album_thumb_preservation.py
Normal 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"
|
||||||
|
|
@ -12,7 +12,7 @@ from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.navidrome_client import NavidromeClient
|
from core.navidrome_client import NavidromeAlbum, NavidromeClient
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -77,3 +77,22 @@ def test_get_all_album_ids_returns_set(nav_client):
|
||||||
|
|
||||||
assert isinstance(result, set)
|
assert isinstance(result, set)
|
||||||
assert result == {'nav-1', 'nav-2'}
|
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'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue