feat: cover-art pipeline (§1D)
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled

Resolve, store and serve album cover art.

Sources (tag-first, mirroring enrichment): embedded artwork extracted
offline via mutagen (ID3 APIC / FLAC+OGG Picture / MP4 covr), then Cover
Art Archive by release-group MBID as a network fallback. Resolution runs
inside MetadataEnrichmentService after album resolution, only when the
album has no cover yet (idempotent, never overwrites), and is best-effort
so a cover failure never affects enrichment status.

- CoverArt value object + CoverArtExtractor/CoverArtProvider ports
- MutagenCoverExtractor + CoverArtArchiveClient adapters
- AcoustID parser now captures release_group_mbid
- Covers stored via FileStorage at covers/{album_id}.{ext} (local + S3)
- AlbumRepository.set_cover_path
- Serve real covers: GET /api/v1/albums|tracks/{id}/cover (StreamUser,
  ?token=), Subsonic getCoverArt (placeholder fallback)
- has_cover flag on AlbumOut/TrackOut
- coverart_enabled / coverart_base_url settings
- tests: cover resolution units + release_group parse + DB-backed
  test_cover_api.py (139 green via make test-api)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Senko-san
2026-06-13 12:10:05 +03:00
parent c7e078d758
commit 0bb752f582
23 changed files with 834 additions and 30 deletions
+1
View File
@@ -33,6 +33,7 @@ def test_parses_full_recording() -> None:
assert match.title == "One More Time"
assert match.artist == "Daft Punk"
assert match.album == "Discovery"
assert match.release_group_mbid == "rg1"
assert match.score == 0.97
+194
View File
@@ -0,0 +1,194 @@
"""Integration tests for the native cover-art endpoints.
Seeds an album with a stored cover, then exercises the ``/api/v1`` album/track
cover endpoints (token auth, 404 when absent). Requires a reachable Postgres;
skips otherwise.
"""
import asyncio
import os
import uuid
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
from app.core.config import get_settings
from app.infrastructure.db import Base, dispose_engine, get_engine, session_scope
from app.infrastructure.db.repositories import (
SqlAlchemyAlbumRepository,
SqlAlchemyArtistRepository,
SqlAlchemyRefreshTokenRepository,
SqlAlchemyTrackRepository,
SqlAlchemyUserRepository,
)
from app.infrastructure.storage.provider import get_file_storage
from asgi_lifespan import LifespanManager
from httpx import ASGITransport, AsyncClient
pytestmark = pytest.mark.asyncio
# A minimal valid 1x1 PNG.
_PNG_BYTES = bytes.fromhex(
"89504e470d0a1a0a0000000d4948445200000001000000010802000000907753"
"de0000000c4944415408d763f8cfc0f01f0005000155a2b4f60000000049454e44ae426082"
)
_db_reachable_cache: bool | None = None
async def _db_reachable() -> bool:
global _db_reachable_cache
if _db_reachable_cache is not None:
return _db_reachable_cache
from sqlalchemy import text
try:
async with asyncio.timeout(3):
async with get_engine().connect() as conn:
await conn.execute(text("SELECT 1"))
_db_reachable_cache = True
except Exception:
_db_reachable_cache = False
return _db_reachable_cache
async def _seed_album_with_cover(*, with_cover: bool) -> tuple[uuid.UUID, uuid.UUID]:
"""Create an artist + album (+ optional cover file) + track. Returns
``(album_id, track_id)``."""
async with session_scope() as session:
artist = await SqlAlchemyArtistRepository(session).get_or_create("Coverart Artist")
album = await SqlAlchemyAlbumRepository(session).get_or_create(
title="Coverart Album", artist_id=artist.id, year=2020, musicbrainz_id=None
)
track = await SqlAlchemyTrackRepository(session).add(
id=uuid.uuid4(),
title="Coverart Track",
artist_id=artist.id,
storage_uri="tracks/zz/cover-track.mp3",
file_format="mp3",
file_size=10,
source="upload",
source_id="cover-src",
metadata_status="enriched",
added_by=None,
)
# Link the track to the album (add() doesn't take album_id).
await SqlAlchemyTrackRepository(session).apply_enrichment(
track.id,
title="Coverart Track",
artist_id=artist.id,
album_id=album.id,
genre=None,
year=2020,
track_number=1,
duration_seconds=1,
bitrate=None,
acoustid_fingerprint=None,
musicbrainz_id=None,
metadata_status="enriched",
)
if with_cover:
key = f"covers/{album.id}.png"
import tempfile
with tempfile.NamedTemporaryFile(suffix=".png") as tmp:
tmp.write(_PNG_BYTES)
tmp.flush()
await get_file_storage().save_file(key, Path(tmp.name))
await SqlAlchemyAlbumRepository(session).set_cover_path(album.id, key)
return album.id, track.id
@pytest.fixture
async def api(tmp_path: Path) -> AsyncIterator[AsyncClient]:
if not await _db_reachable():
pytest.skip("Postgres not reachable — integration test skipped.")
os.environ["MEDIA_PATH"] = str(tmp_path)
get_settings.cache_clear()
import app.infrastructure.storage.provider as _storage_provider
_storage_provider._storage = None
try:
async with get_engine().begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
from app.application.user_service import UserService
from app.core.security import Argon2PasswordHasher
async with session_scope() as session:
await UserService(
users=SqlAlchemyUserRepository(session),
refresh_tokens=SqlAlchemyRefreshTokenRepository(session),
hasher=Argon2PasswordHasher(),
).create_user(username="testuser", password="testpass1", is_superuser=False)
from app.main import create_app
app = create_app()
async with LifespanManager(app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
async with get_engine().begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await dispose_engine()
finally:
_storage_provider._storage = None
os.environ.pop("MEDIA_PATH", None)
get_settings.cache_clear()
async def _login(api: AsyncClient) -> str:
resp = await api.post(
"/api/v1/auth/login", json={"username": "testuser", "password": "testpass1"}
)
assert resp.status_code == 200
return str(resp.json()["access_token"])
async def test_album_cover_served(api: AsyncClient) -> None:
token = await _login(api)
album_id, _ = await _seed_album_with_cover(with_cover=True)
resp = await api.get(f"/api/v1/albums/{album_id}/cover?token={token}")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "image/png"
assert resp.content == _PNG_BYTES
async def test_track_cover_served_from_album(api: AsyncClient) -> None:
token = await _login(api)
_, track_id = await _seed_album_with_cover(with_cover=True)
resp = await api.get(f"/api/v1/tracks/{track_id}/cover?token={token}")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "image/png"
assert resp.content == _PNG_BYTES
async def test_album_without_cover_is_404(api: AsyncClient) -> None:
token = await _login(api)
album_id, _ = await _seed_album_with_cover(with_cover=False)
resp = await api.get(f"/api/v1/albums/{album_id}/cover?token={token}")
assert resp.status_code == 404
async def test_cover_requires_auth(api: AsyncClient) -> None:
album_id, _ = await _seed_album_with_cover(with_cover=True)
resp = await api.get(f"/api/v1/albums/{album_id}/cover")
assert resp.status_code == 401
async def test_album_appears_with_has_cover_flag(api: AsyncClient) -> None:
token = await _login(api)
album_id, _ = await _seed_album_with_cover(with_cover=True)
resp = await api.get(f"/api/v1/albums/{album_id}", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
assert resp.json()["has_cover"] is True
+150 -2
View File
@@ -15,6 +15,7 @@ import pytest
from app.application.metadata_service import MetadataEnrichmentService
from app.domain.entities import Artist, Track
from app.domain.entities.album import Album
from app.domain.entities.cover import CoverArt
from app.domain.entities.metadata import AudioTags, Fingerprint, RecordingMatch
pytestmark = pytest.mark.asyncio
@@ -67,8 +68,10 @@ class FakeArtistRepo:
class FakeAlbumRepo:
def __init__(self) -> None:
def __init__(self, *, cover_path: str | None = None) -> None:
self.created: list[tuple[str, uuid.UUID]] = []
self.covers: dict[uuid.UUID, str] = {}
self._existing_cover = cover_path
async def get_or_create(
self, *, title: str, artist_id: uuid.UUID, year: int | None, musicbrainz_id: str | None
@@ -80,18 +83,52 @@ class FakeAlbumRepo:
title=title,
artist_id=artist_id,
year=year,
cover_path=None,
cover_path=self._existing_cover,
musicbrainz_id=musicbrainz_id,
created_at=now,
updated_at=now,
)
async def set_cover_path(self, album_id: uuid.UUID, cover_path: str) -> None:
self.covers[album_id] = cover_path
class FakeStorage:
def __init__(self) -> None:
self.saved: list[str] = []
@asynccontextmanager
async def as_local_path(self, key: str) -> AsyncIterator[Path]:
yield Path("/tmp") / key
async def save_file(self, key: str, src_path: Path) -> int:
self.saved.append(key)
return 1
class FakeCoverExtractor:
def __init__(self, cover: CoverArt | None) -> None:
self._cover = cover
self.calls = 0
async def extract(self, path: Path) -> CoverArt | None:
self.calls += 1
return self._cover
class FakeCoverProvider:
def __init__(self, cover: CoverArt | None, *, available: bool = True) -> None:
self._cover = cover
self._available = available
self.calls = 0
def is_available(self) -> bool:
return self._available
async def fetch_release_group(self, release_group_mbid: str) -> CoverArt | None:
self.calls += 1
return self._cover
class FakeTagReader:
def __init__(self, tags: AudioTags | None) -> None:
@@ -281,3 +318,114 @@ async def test_fingerprint_skipped_when_acoustid_unavailable() -> None:
# tags still enrich, but no AcoustID call is attempted
assert acoustid.calls == 0
assert result.status == "enriched"
# -- cover-art resolution -----------------------------------------------------
_PNG = CoverArt(data=b"\x89PNG\r\n", content_type="image/png")
_JPG = CoverArt(data=b"\xff\xd8\xff", content_type="image/jpeg")
def _cover_service(
*,
track: Track,
tags: AudioTags | None = None,
match: RecordingMatch | None = None,
fp: Fingerprint | None = None,
extractor: FakeCoverExtractor | None = None,
provider: FakeCoverProvider | None = None,
existing_cover: str | None = None,
) -> tuple[MetadataEnrichmentService, FakeAlbumRepo, FakeStorage]:
albums = FakeAlbumRepo(cover_path=existing_cover)
storage = FakeStorage()
service = MetadataEnrichmentService(
tracks=FakeTrackRepo(track), # type: ignore[arg-type]
artists=FakeArtistRepo(), # type: ignore[arg-type]
albums=albums, # type: ignore[arg-type]
storage=storage, # type: ignore[arg-type]
tag_reader=FakeTagReader(tags), # type: ignore[arg-type]
fingerprinter=FakeFingerprinter(fp), # type: ignore[arg-type]
acoustid=FakeAcoustId(match), # type: ignore[arg-type]
cover_extractor=extractor, # type: ignore[arg-type]
cover_provider=provider, # type: ignore[arg-type]
)
return service, albums, storage
async def test_cover_extracted_from_embedded_art() -> None:
track = _track()
extractor = FakeCoverExtractor(_PNG)
provider = FakeCoverProvider(_JPG)
service, albums, storage = _cover_service(
track=track, tags=AudioTags(album="The Wall", artist="PF"),
extractor=extractor, provider=provider,
)
await service.enrich(track.id)
assert extractor.calls == 1
assert provider.calls == 0 # embedded art wins → no network fetch
assert len(albums.covers) == 1
key = next(iter(albums.covers.values()))
assert key.startswith("covers/") and key.endswith(".png")
assert storage.saved == [key]
async def test_cover_falls_back_to_archive() -> None:
track = _track()
extractor = FakeCoverExtractor(None) # no embedded art
provider = FakeCoverProvider(_JPG)
match = RecordingMatch(acoustid="ac", score=1.0, release_group_mbid="rg-123", album="The Wall")
fp = Fingerprint(fingerprint="AQAA", duration_seconds=200)
service, albums, storage = _cover_service(
track=track, tags=AudioTags(album="The Wall", artist="PF"),
match=match, fp=fp, extractor=extractor, provider=provider,
)
await service.enrich(track.id)
assert extractor.calls == 1
assert provider.calls == 1
key = next(iter(albums.covers.values()))
assert key.endswith(".jpg")
assert storage.saved == [key]
async def test_cover_not_fetched_without_release_group() -> None:
track = _track()
provider = FakeCoverProvider(_JPG)
service, albums, _ = _cover_service(
track=track, tags=AudioTags(album="The Wall", artist="PF"),
extractor=FakeCoverExtractor(None), provider=provider,
)
await service.enrich(track.id)
assert provider.calls == 0 # no release-group mbid → nothing to look up
assert albums.covers == {}
async def test_existing_cover_is_not_overwritten() -> None:
track = _track()
extractor = FakeCoverExtractor(_PNG)
service, albums, storage = _cover_service(
track=track, tags=AudioTags(album="The Wall", artist="PF"),
extractor=extractor, existing_cover="covers/old.jpg",
)
await service.enrich(track.id)
assert extractor.calls == 0 # album already has a cover → skip entirely
assert albums.covers == {}
assert storage.saved == []
async def test_cover_skipped_when_no_album() -> None:
track = _track()
extractor = FakeCoverExtractor(_PNG)
# no album tag and no match → no album resolved → no cover work
service, _albums, storage = _cover_service(track=track, extractor=extractor)
await service.enrich(track.id)
assert extractor.calls == 0
assert storage.saved == []
+1 -3
View File
@@ -175,9 +175,7 @@ async def test_stream_range(api: AsyncClient) -> None:
async def test_stream_not_found(api: AsyncClient) -> None:
token = await _login(api)
resp = await api.get(
f"/api/v1/stream/00000000-0000-0000-0000-000000000000?token={token}"
)
resp = await api.get(f"/api/v1/stream/00000000-0000-0000-0000-000000000000?token={token}")
assert resp.status_code == 404