0bb752f582
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>
195 lines
6.7 KiB
Python
195 lines
6.7 KiB
Python
"""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
|