feat(metadata): implement single-track metadata editor API (§A7/§1H)
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user