feat(metadata): implement single-track metadata editor API (§A7/§1H)
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled

Adds inline AcoustID match-finding (multiple ranked candidates via
lookup_all) and PUT /tracks/{id}/metadata for manual edits, resolving
artist/album and setting metadata_status=manual. Extends TrackOut with
genre/year/track_number.
This commit is contained in:
Senko-san
2026-06-13 14:34:43 +03:00
parent 73d7da440f
commit 63c7d05eca
14 changed files with 438 additions and 16 deletions
@@ -38,6 +38,7 @@ def _track_to_entity(row: TrackModel) -> Track:
duration_seconds=row.duration_seconds,
genre=row.genre,
year=row.year,
track_number=row.track_number,
metadata_status=row.metadata_status,
metadata_error=row.metadata_error,
enriched_at=row.enriched_at,
@@ -37,6 +37,7 @@ def _track_to_entity(row: TrackModel) -> Track:
duration_seconds=row.duration_seconds,
genre=row.genre,
year=row.year,
track_number=row.track_number,
metadata_status=row.metadata_status,
metadata_error=row.metadata_error,
enriched_at=row.enriched_at,
@@ -26,6 +26,7 @@ def _to_entity(row: TrackModel) -> Track:
duration_seconds=row.duration_seconds,
genre=row.genre,
year=row.year,
track_number=row.track_number,
metadata_status=row.metadata_status,
metadata_error=row.metadata_error,
enriched_at=row.enriched_at,
@@ -162,6 +163,9 @@ class SqlAlchemyTrackRepository:
title: str | None,
genre: str | None,
year: int | None,
artist_id: uuid.UUID | None = None,
album_id: uuid.UUID | None = None,
track_number: int | None = None,
) -> Track:
row = await self._session.get(TrackModel, track_id)
if row is None:
@@ -172,6 +176,12 @@ class SqlAlchemyTrackRepository:
row.genre = genre
if year is not None:
row.year = year
if artist_id is not None:
row.artist_id = artist_id
if album_id is not None:
row.album_id = album_id
if track_number is not None:
row.track_number = track_number
row.metadata_status = "manual"
await self._session.flush()
await self._session.refresh(row)
+39 -12
View File
@@ -46,6 +46,18 @@ class AcoustIdHttpClient:
return bool(self._api_key)
async def lookup(self, fingerprint: Fingerprint) -> RecordingMatch | None:
payload = await self._lookup_raw(fingerprint)
if payload is None:
return None
return _parse_best_match(payload)
async def lookup_all(self, fingerprint: Fingerprint) -> list[RecordingMatch]:
payload = await self._lookup_raw(fingerprint)
if payload is None:
return []
return _parse_matches(payload)
async def _lookup_raw(self, fingerprint: Fingerprint) -> object | None:
if not self._api_key:
return None
try:
@@ -65,13 +77,11 @@ class AcoustIdHttpClient:
},
)
resp.raise_for_status()
payload = resp.json()
return resp.json() # type: ignore[no-any-return]
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:
@@ -82,22 +92,39 @@ class AcoustIdHttpClient:
cls._last_call_monotonic = time.monotonic()
_MAX_MATCHES = 5
def _parse_best_match(payload: object) -> RecordingMatch | None:
matches = _parse_matches(payload)
return matches[0] if matches else None
def _parse_matches(payload: object) -> list[RecordingMatch]:
if not isinstance(payload, dict) or payload.get("status") != "ok":
return None
return []
results = payload.get("results")
if not isinstance(results, list) or not results:
return None
return []
# 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
# Results are returned best-score-first, but sort defensively and cap the
# number of candidates surfaced to the editor.
candidates = [r for r in results if isinstance(r, dict)]
candidates.sort(key=lambda r: r.get("score", 0.0), reverse=True)
acoustid = best.get("id")
matches: list[RecordingMatch] = []
for result in candidates[:_MAX_MATCHES]:
match = _parse_one(result)
if match is not None:
matches.append(match)
return matches
def _parse_one(result: dict[str, object]) -> RecordingMatch | None:
acoustid = result.get("id")
if not isinstance(acoustid, str):
return None
score = float(best.get("score", 0.0))
score = float(result.get("score", 0.0)) # type: ignore[arg-type]
recording_mbid: str | None = None
release_group_mbid: str | None = None
@@ -105,7 +132,7 @@ def _parse_best_match(payload: object) -> RecordingMatch | None:
artist: str | None = None
album: str | None = None
recordings = best.get("recordings")
recordings = result.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