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>
63 lines
2.1 KiB
Python
63 lines
2.1 KiB
Python
"""FpcalcFingerprinter — Chromaprint fingerprint via the ``fpcalc`` binary.
|
|
|
|
``fpcalc -json <file>`` emits ``{"duration": float, "fingerprint": str}``. The
|
|
binary ships in the Docker image (``libchromaprint-tools``). Any failure (binary
|
|
missing, bad file, timeout) degrades to ``None`` — the pipeline then falls back
|
|
to tag-only metadata (plan §6.2: one external dependency must never crash it).
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from app.core.logging import get_logger
|
|
from app.domain.entities.metadata import Fingerprint
|
|
|
|
log = get_logger(__name__)
|
|
|
|
_TIMEOUT_SECONDS = 30
|
|
|
|
|
|
class FpcalcFingerprinter:
|
|
"""Implements :class:`app.domain.ports.AudioFingerprinter`."""
|
|
|
|
def __init__(self, binary: str = "fpcalc") -> None:
|
|
self._binary = binary
|
|
|
|
def is_available(self) -> bool:
|
|
return shutil.which(self._binary) is not None
|
|
|
|
async def calculate(self, path: Path) -> Fingerprint | None:
|
|
if not self.is_available():
|
|
return None
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
self._binary,
|
|
"-json",
|
|
str(path),
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
async with asyncio.timeout(_TIMEOUT_SECONDS):
|
|
stdout, _stderr = await proc.communicate()
|
|
except TimeoutError, OSError:
|
|
log.warning("fpcalc_failed", path=str(path))
|
|
return None
|
|
|
|
if proc.returncode != 0:
|
|
log.warning("fpcalc_nonzero", path=str(path), returncode=proc.returncode)
|
|
return None
|
|
|
|
try:
|
|
data = json.loads(stdout)
|
|
fingerprint = str(data["fingerprint"])
|
|
duration = round(float(data["duration"]))
|
|
except json.JSONDecodeError, KeyError, ValueError:
|
|
log.warning("fpcalc_bad_output", path=str(path))
|
|
return None
|
|
|
|
if not fingerprint or duration <= 0:
|
|
return None
|
|
return Fingerprint(fingerprint=fingerprint, duration_seconds=duration)
|