feat(enrichment): tag-first metadata pipeline (§1D)
Implements the §6.2 enrichment pipeline: embedded tags → Chromaprint
fingerprint → AcoustID lookup. Well-tagged files get correct
artist/album/title offline; the rest are identified via AcoustID
(which also yields a MusicBrainz recording id in one call).
- domain: AudioTags/Fingerprint/RecordingMatch value objects; ports
AudioTagReader, AudioFingerprinter, AcoustIdClient; TrackRepository
.apply_enrichment (gap-fill, never erases) + AlbumRepository.get_or_create
- infrastructure/metadata: MutagenTagReader, FpcalcFingerprinter,
AcoustIdHttpClient (rich meta=recordings+releasegroups, throttled)
- application: MetadataEnrichmentService — tags preferred, AcoustID fills
gaps; resolves artist/album; status enriched/failed; skips manual;
every external step wrapped (graceful degradation)
- workers: enrich_task registered; enqueue_enrich is best-effort and
deferred so the caller's txn commits before the worker reads the row
- wiring: upload enqueues after add; import returns imported_ids and
enqueues post-commit (mid-scan would race the worker); manual
POST /tracks/{id}/metadata/enrich endpoint
- deps: add mutagen (fpcalc/ffmpeg already in the image)
Tests: metadata service orchestration, AcoustID parser, tag helpers.
125 passed; mypy strict + ruff clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Metadata-enrichment adapters: tag reader, fingerprinter, AcoustID client."""
|
||||
@@ -0,0 +1,129 @@
|
||||
"""AcoustIdHttpClient — identifies a recording from its Chromaprint fingerprint.
|
||||
|
||||
One ``/v2/lookup`` call with ``meta=recordings+releasegroups`` returns the
|
||||
AcoustID id, the MusicBrainz recording id, and canonical title/artist/album —
|
||||
metadata that itself originates from MusicBrainz, so a separate MB call is not
|
||||
needed for Phase 1 (plan §6.2 steps 2-3 collapsed into one request).
|
||||
|
||||
Graceful degradation: no API key → ``is_available()`` is False and the whole
|
||||
fingerprint path is skipped; any network/parse error → ``lookup`` returns
|
||||
``None``. A small inter-call delay keeps us within AcoustID's rate limit.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.domain.entities.metadata import Fingerprint, RecordingMatch
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
_DEFAULT_URL = "https://api.acoustid.org/v2/lookup"
|
||||
_TIMEOUT_SECONDS = 10.0
|
||||
_MIN_INTERVAL_SECONDS = 0.34 # AcoustID allows ~3 req/s; stay polite
|
||||
|
||||
|
||||
class AcoustIdHttpClient:
|
||||
"""Implements :class:`app.domain.ports.AcoustIdClient`."""
|
||||
|
||||
_throttle_lock = asyncio.Lock()
|
||||
_last_call_monotonic = 0.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None,
|
||||
user_agent: str,
|
||||
api_url: str = _DEFAULT_URL,
|
||||
) -> None:
|
||||
self._api_key = api_key
|
||||
self._user_agent = user_agent
|
||||
self._api_url = api_url
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return bool(self._api_key)
|
||||
|
||||
async def lookup(self, fingerprint: Fingerprint) -> RecordingMatch | None:
|
||||
if not self._api_key:
|
||||
return None
|
||||
try:
|
||||
await self._throttle()
|
||||
async with httpx.AsyncClient(
|
||||
timeout=_TIMEOUT_SECONDS,
|
||||
headers={"User-Agent": self._user_agent},
|
||||
) as client:
|
||||
resp = await client.get(
|
||||
self._api_url,
|
||||
params={
|
||||
"client": self._api_key,
|
||||
"duration": str(fingerprint.duration_seconds),
|
||||
"fingerprint": fingerprint.fingerprint,
|
||||
"meta": "recordings releasegroups",
|
||||
"format": "json",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except (httpx.HTTPError, ValueError):
|
||||
log.warning("acoustid_lookup_failed")
|
||||
return None
|
||||
|
||||
return _parse_best_match(payload)
|
||||
|
||||
@classmethod
|
||||
async def _throttle(cls) -> None:
|
||||
async with cls._throttle_lock:
|
||||
elapsed = time.monotonic() - cls._last_call_monotonic
|
||||
wait = _MIN_INTERVAL_SECONDS - elapsed
|
||||
if wait > 0:
|
||||
await asyncio.sleep(wait)
|
||||
cls._last_call_monotonic = time.monotonic()
|
||||
|
||||
|
||||
def _parse_best_match(payload: object) -> RecordingMatch | None:
|
||||
if not isinstance(payload, dict) or payload.get("status") != "ok":
|
||||
return None
|
||||
results = payload.get("results")
|
||||
if not isinstance(results, list) or not results:
|
||||
return None
|
||||
|
||||
# Results are returned best-score-first; take the top scoring one.
|
||||
best = max(results, key=lambda r: r.get("score", 0.0) if isinstance(r, dict) else 0.0)
|
||||
if not isinstance(best, dict):
|
||||
return None
|
||||
|
||||
acoustid = best.get("id")
|
||||
if not isinstance(acoustid, str):
|
||||
return None
|
||||
score = float(best.get("score", 0.0))
|
||||
|
||||
recording_mbid: str | None = None
|
||||
title: str | None = None
|
||||
artist: str | None = None
|
||||
album: str | None = None
|
||||
|
||||
recordings = best.get("recordings")
|
||||
if isinstance(recordings, list) and recordings and isinstance(recordings[0], dict):
|
||||
rec = recordings[0]
|
||||
recording_mbid = rec.get("id") if isinstance(rec.get("id"), str) else None
|
||||
title = rec.get("title") if isinstance(rec.get("title"), str) else None
|
||||
artists = rec.get("artists")
|
||||
if isinstance(artists, list) and artists and isinstance(artists[0], dict):
|
||||
name = artists[0].get("name")
|
||||
artist = name if isinstance(name, str) else None
|
||||
groups = rec.get("releasegroups")
|
||||
if isinstance(groups, list) and groups and isinstance(groups[0], dict):
|
||||
gtitle = groups[0].get("title")
|
||||
album = gtitle if isinstance(gtitle, str) else None
|
||||
|
||||
return RecordingMatch(
|
||||
acoustid=acoustid,
|
||||
score=score,
|
||||
recording_mbid=recording_mbid,
|
||||
title=title,
|
||||
artist=artist,
|
||||
album=album,
|
||||
year=None,
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""MutagenTagReader — reads embedded tags from a local audio file.
|
||||
|
||||
The offline first pass of enrichment (plan §6.2): well-tagged files get correct
|
||||
artist/album/title without any network call. mutagen's ``easy=True`` mode
|
||||
normalises tag keys across ID3 / Vorbis / MP4, so one code path covers all the
|
||||
formats the library accepts. Parsing is blocking, so it runs in a worker thread.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
from mutagen import File as MutagenFile # type: ignore[attr-defined]
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.domain.entities.metadata import AudioTags
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
_YEAR_RE = re.compile(r"(\d{4})")
|
||||
|
||||
|
||||
def _first(value: object) -> str | None:
|
||||
"""EasyXxx tags expose values as lists; take the first non-empty string."""
|
||||
if isinstance(value, list):
|
||||
value = value[0] if value else None
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _parse_year(value: object) -> int | None:
|
||||
text = _first(value)
|
||||
if text is None:
|
||||
return None
|
||||
m = _YEAR_RE.search(text)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def _parse_track_number(value: object) -> int | None:
|
||||
text = _first(value)
|
||||
if text is None:
|
||||
return None
|
||||
# "3" or "3/12" → 3
|
||||
head = text.split("/", 1)[0].strip()
|
||||
return int(head) if head.isdigit() else None
|
||||
|
||||
|
||||
class MutagenTagReader:
|
||||
"""Implements :class:`app.domain.ports.AudioTagReader`."""
|
||||
|
||||
async def read(self, path: Path) -> AudioTags | None:
|
||||
try:
|
||||
return await anyio.to_thread.run_sync(self._read_sync, path)
|
||||
except Exception:
|
||||
log.warning("tag_read_failed", path=str(path))
|
||||
return None
|
||||
|
||||
def _read_sync(self, path: Path) -> AudioTags | None:
|
||||
audio = MutagenFile(str(path), easy=True)
|
||||
if audio is None:
|
||||
return None # unrecognised container
|
||||
|
||||
tags = audio.tags or {}
|
||||
info = getattr(audio, "info", None)
|
||||
|
||||
duration = None
|
||||
bitrate = None
|
||||
if info is not None:
|
||||
length = getattr(info, "length", None)
|
||||
if length:
|
||||
duration = round(float(length))
|
||||
raw_bitrate = getattr(info, "bitrate", None)
|
||||
if raw_bitrate:
|
||||
bitrate = int(raw_bitrate) // 1000 # bits/s → kbps for display
|
||||
|
||||
return AudioTags(
|
||||
title=_first(tags.get("title")),
|
||||
artist=_first(tags.get("artist")),
|
||||
album=_first(tags.get("album")),
|
||||
album_artist=_first(tags.get("albumartist")),
|
||||
genre=_first(tags.get("genre")),
|
||||
year=_parse_year(tags.get("date") or tags.get("year")),
|
||||
track_number=_parse_track_number(tags.get("tracknumber")),
|
||||
duration_seconds=duration,
|
||||
bitrate=bitrate,
|
||||
)
|
||||
Reference in New Issue
Block a user