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:
@@ -15,6 +15,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.auth_service import AuthService
|
||||
from app.application.metadata_service import MetadataEnrichmentService
|
||||
from app.application.streaming_service import StreamingService
|
||||
from app.application.subsonic_auth_service import SubsonicAuthService
|
||||
from app.application.upload_service import UploadService
|
||||
@@ -35,6 +36,9 @@ from app.infrastructure.db.repositories import (
|
||||
SqlAlchemyTrackRepository,
|
||||
SqlAlchemyUserRepository,
|
||||
)
|
||||
from app.infrastructure.metadata.acoustid import AcoustIdHttpClient
|
||||
from app.infrastructure.metadata.fingerprint import FpcalcFingerprinter
|
||||
from app.infrastructure.metadata.tags import MutagenTagReader
|
||||
from app.infrastructure.sources.registry import SourceRegistry, build_source_registry
|
||||
from app.infrastructure.storage.provider import get_file_storage
|
||||
from app.workers.queue import enqueue_enrich
|
||||
@@ -132,8 +136,34 @@ def get_streaming_service(session: SessionDep, storage: FileStorageDep) -> Strea
|
||||
)
|
||||
|
||||
|
||||
def get_metadata_service(
|
||||
session: SessionDep, storage: FileStorageDep
|
||||
) -> MetadataEnrichmentService:
|
||||
"""Wires the §6.2 fingerprint/AcoustID adapters for read-only, inline use
|
||||
(the metadata editor's "find matches" — §A7). The full pipeline (incl.
|
||||
cover art) stays in the worker (`tasks/enrich_task.py`)."""
|
||||
settings = get_settings()
|
||||
api_key = settings.acoustid_api_key.get_secret_value() if settings.acoustid_api_key else None
|
||||
acoustid = AcoustIdHttpClient(
|
||||
api_key=api_key,
|
||||
user_agent=settings.musicbrainz_user_agent,
|
||||
api_url=settings.acoustid_api_url,
|
||||
)
|
||||
return MetadataEnrichmentService(
|
||||
tracks=SqlAlchemyTrackRepository(session),
|
||||
artists=SqlAlchemyArtistRepository(session),
|
||||
albums=SqlAlchemyAlbumRepository(session),
|
||||
storage=storage,
|
||||
tag_reader=MutagenTagReader(),
|
||||
fingerprinter=FpcalcFingerprinter(settings.fpcalc_path),
|
||||
acoustid=acoustid,
|
||||
acoustid_trust_score=settings.acoustid_trust_score,
|
||||
)
|
||||
|
||||
|
||||
UploadServiceDep = Annotated[UploadService, Depends(get_upload_service)]
|
||||
StreamingServiceDep = Annotated[StreamingService, Depends(get_streaming_service)]
|
||||
MetadataServiceDep = Annotated[MetadataEnrichmentService, Depends(get_metadata_service)]
|
||||
|
||||
|
||||
# -- library repository deps ---------------------------------------------------
|
||||
|
||||
@@ -16,6 +16,9 @@ class TrackOut(BaseModel):
|
||||
duration_seconds: int | None
|
||||
file_format: str
|
||||
file_size: int
|
||||
genre: str | None
|
||||
year: int | None
|
||||
track_number: int | None
|
||||
metadata_status: str
|
||||
metadata_error: str | None
|
||||
enriched_at: dt.datetime | None
|
||||
@@ -28,3 +31,33 @@ class TrackUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
genre: str | None = None
|
||||
year: int | None = None
|
||||
|
||||
|
||||
class MetadataMatch(BaseModel):
|
||||
"""One AcoustID candidate for the metadata editor's match picker (§A7)."""
|
||||
|
||||
acoustid: str
|
||||
score: float
|
||||
recording_mbid: str | None
|
||||
release_group_mbid: str | None
|
||||
title: str | None
|
||||
artist: str | None
|
||||
album: str | None
|
||||
year: int | None
|
||||
|
||||
|
||||
class MetadataMatchesOut(BaseModel):
|
||||
items: list[MetadataMatch]
|
||||
|
||||
|
||||
class MetadataApply(BaseModel):
|
||||
"""Manual edits / accepted match applied via ``PUT /tracks/{id}/metadata``.
|
||||
|
||||
Sets ``metadata_status = manual`` (never overwritten by auto-enrichment)."""
|
||||
|
||||
title: str | None = None
|
||||
artist_name: str | None = None
|
||||
album_title: str | None = None
|
||||
year: int | None = None
|
||||
genre: str | None = None
|
||||
track_number: int | None = None
|
||||
|
||||
+88
-3
@@ -12,11 +12,18 @@ from app.api.deps import (
|
||||
ArtistRepoDep,
|
||||
CurrentUser,
|
||||
FileStorageDep,
|
||||
MetadataServiceDep,
|
||||
StreamUser,
|
||||
TrackRepoDep,
|
||||
)
|
||||
from app.api.schemas.pagination import PagedResponse
|
||||
from app.api.schemas.track import TrackOut, TrackUpdate
|
||||
from app.api.schemas.track import (
|
||||
MetadataApply,
|
||||
MetadataMatch,
|
||||
MetadataMatchesOut,
|
||||
TrackOut,
|
||||
TrackUpdate,
|
||||
)
|
||||
from app.domain.entities.album import Album
|
||||
from app.domain.entities.track import Artist, Track
|
||||
from app.domain.errors import NotFoundError
|
||||
@@ -41,6 +48,9 @@ async def _build_track_out(
|
||||
duration_seconds=t.duration_seconds,
|
||||
file_format=t.file_format,
|
||||
file_size=t.file_size,
|
||||
genre=t.genre,
|
||||
year=t.year,
|
||||
track_number=t.track_number,
|
||||
metadata_status=t.metadata_status,
|
||||
metadata_error=t.metadata_error,
|
||||
enriched_at=t.enriched_at,
|
||||
@@ -187,8 +197,83 @@ async def enrich_metadata(
|
||||
|
||||
|
||||
@router.get("/{track_id}/metadata/matches")
|
||||
async def get_metadata_matches(track_id: uuid.UUID, _: CurrentUser) -> Any: ...
|
||||
async def get_metadata_matches(
|
||||
track_id: uuid.UUID,
|
||||
track_repo: TrackRepoDep,
|
||||
metadata_service: MetadataServiceDep,
|
||||
_: CurrentUser,
|
||||
) -> MetadataMatchesOut:
|
||||
"""AcoustID candidates for the metadata editor's match picker (§A7).
|
||||
|
||||
Runs the fingerprint lookup inline (single track, user-triggered) and
|
||||
never mutates the track. Degrades to an empty list if fpcalc/AcoustID are
|
||||
unavailable or no match is found.
|
||||
"""
|
||||
track = await track_repo.get_by_id(track_id)
|
||||
if track is None:
|
||||
raise NotFoundError(f"Track {track_id} not found.")
|
||||
matches = await metadata_service.find_matches(track_id)
|
||||
return MetadataMatchesOut(
|
||||
items=[
|
||||
MetadataMatch(
|
||||
acoustid=m.acoustid,
|
||||
score=m.score,
|
||||
recording_mbid=m.recording_mbid,
|
||||
release_group_mbid=m.release_group_mbid,
|
||||
title=m.title,
|
||||
artist=m.artist,
|
||||
album=m.album,
|
||||
year=m.year,
|
||||
)
|
||||
for m in matches
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{track_id}/metadata")
|
||||
async def set_metadata(track_id: uuid.UUID, _: CurrentUser) -> Any: ...
|
||||
async def set_metadata(
|
||||
track_id: uuid.UUID,
|
||||
body: MetadataApply,
|
||||
track_repo: TrackRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
_: CurrentUser,
|
||||
) -> TrackOut:
|
||||
"""Apply manual edits or an accepted AcoustID match (§A7). Sets
|
||||
``metadata_status = manual`` — never overwritten by auto-enrichment."""
|
||||
track = await track_repo.get_by_id(track_id)
|
||||
if track is None:
|
||||
raise NotFoundError(f"Track {track_id} not found.")
|
||||
|
||||
artist_id: uuid.UUID | None = None
|
||||
if body.artist_name:
|
||||
artist = await artist_repo.get_or_create(body.artist_name)
|
||||
artist_id = artist.id
|
||||
|
||||
album_id: uuid.UUID | None = None
|
||||
if body.album_title:
|
||||
album = await album_repo.get_or_create(
|
||||
title=body.album_title,
|
||||
artist_id=artist_id or track.artist_id,
|
||||
year=body.year,
|
||||
musicbrainz_id=None,
|
||||
)
|
||||
album_id = album.id
|
||||
|
||||
track = await track_repo.update(
|
||||
track_id,
|
||||
title=body.title,
|
||||
genre=body.genre,
|
||||
year=body.year,
|
||||
artist_id=artist_id,
|
||||
album_id=album_id,
|
||||
track_number=body.track_number,
|
||||
)
|
||||
|
||||
artist_ids = [track.artist_id]
|
||||
album_ids = [track.album_id] if track.album_id else []
|
||||
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
|
||||
albums = {a.id: a for a in await album_repo.get_many(album_ids)}
|
||||
|
||||
items = await _build_track_out([track], artists, albums)
|
||||
return items[0]
|
||||
|
||||
Reference in New Issue
Block a user