feat(lyrics): LRCLIB provider + cached lyrics endpoints (§6.7)

GET /tracks/{id}/lyrics (get-or-fetch, caches found/not_found with a 7-day
miss TTL) and POST /tracks/{id}/lyrics/refetch (force). Hexagonal wiring:
LyricsProvider/LyricsRepository ports, LrclibHttpClient adapter (keyless,
degrades to not_found on error), SqlAlchemyLyricsRepository (upsert on the
existing lyrics table), LyricsService, LyricsOut schema, deps.py factory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Цвылев Александр Вадимович
2026-07-28 20:58:28 +03:00
parent 048854f92a
commit 8271de34eb
9 changed files with 413 additions and 0 deletions
+17
View File
@@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.application.auth_service import AuthService
from app.application.download_service import DownloadService
from app.application.lyrics_service import LyricsService
from app.application.metadata_service import MetadataEnrichmentService
from app.application.remote_library_service import RemoteLibraryService
from app.application.streaming_service import StreamingService
@@ -38,6 +39,7 @@ from app.infrastructure.db.repositories import (
SqlAlchemyDownloadJobRepository,
SqlAlchemyHistoryRepository,
SqlAlchemyLikeRepository,
SqlAlchemyLyricsRepository,
SqlAlchemyPlaylistRepository,
SqlAlchemyRefreshTokenRepository,
SqlAlchemyTrackRepository,
@@ -46,6 +48,7 @@ from app.infrastructure.db.repositories import (
)
from app.infrastructure.metadata.acoustid import AcoustIdHttpClient
from app.infrastructure.metadata.fingerprint import FpcalcFingerprinter
from app.infrastructure.metadata.lrclib import LrclibHttpClient
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
@@ -175,6 +178,19 @@ def get_metadata_service(session: SessionDep, storage: FileStorageDep) -> Metada
)
def get_lyrics_service(session: SessionDep) -> LyricsService:
"""Wires the LRCLIB lyrics provider + cache repo (plan §6.7). LRCLIB is
keyless, so this is always available; failures degrade to ``not_found``."""
settings = get_settings()
return LyricsService(
lyrics=SqlAlchemyLyricsRepository(session),
tracks=SqlAlchemyTrackRepository(session),
artists=SqlAlchemyArtistRepository(session),
albums=SqlAlchemyAlbumRepository(session),
provider=LrclibHttpClient(user_agent=settings.musicbrainz_user_agent),
)
def get_download_service(session: SessionDep, storage: FileStorageDep) -> DownloadService:
return DownloadService(
jobs=SqlAlchemyDownloadJobRepository(session),
@@ -198,6 +214,7 @@ def get_remote_library_service(session: SessionDep) -> RemoteLibraryService:
UploadServiceDep = Annotated[UploadService, Depends(get_upload_service)]
StreamingServiceDep = Annotated[StreamingService, Depends(get_streaming_service)]
MetadataServiceDep = Annotated[MetadataEnrichmentService, Depends(get_metadata_service)]
LyricsServiceDep = Annotated[LyricsService, Depends(get_lyrics_service)]
DownloadServiceDep = Annotated[DownloadService, Depends(get_download_service)]
RemoteLibraryServiceDep = Annotated[RemoteLibraryService, Depends(get_remote_library_service)]
+33
View File
@@ -0,0 +1,33 @@
"""Lyrics response schema (§6.7 / Now Playing lyrics panel).
Returns the raw LRC (``synced``) and/or ``plain`` text; the client parses LRC
timestamps for synced highlighting. A miss is a normal 200 with
``status="not_found"`` and null text — not an error — so the panel can render a
"no lyrics" state.
"""
import uuid
from pydantic import BaseModel
from app.domain.entities.lyrics import Lyrics
class LyricsOut(BaseModel):
track_id: uuid.UUID
status: str
source: str | None
synced: str | None
plain: str | None
synced_available: bool
@classmethod
def from_entity(cls, lyrics: Lyrics) -> LyricsOut:
return cls(
track_id=lyrics.track_id,
status=lyrics.status,
source=lyrics.source,
synced=lyrics.synced,
plain=lyrics.plain,
synced_available=lyrics.synced is not None,
)
+20
View File
@@ -12,12 +12,14 @@ from app.api.deps import (
ArtistRepoDep,
CurrentUser,
FileStorageDep,
LyricsServiceDep,
MetadataServiceDep,
RemoteLibraryServiceDep,
StreamUser,
TrackRepoDep,
)
from app.api.schemas.download import DownloadJobOut
from app.api.schemas.lyrics import LyricsOut
from app.api.schemas.pagination import PagedResponse
from app.api.schemas.track import (
MaterializeResponse,
@@ -224,6 +226,24 @@ async def get_similar_tracks(track_id: uuid.UUID, _: CurrentUser) -> Any: ...
async def optimize_track(track_id: uuid.UUID, _: CurrentUser) -> Any: ...
@router.get("/{track_id}/lyrics")
async def get_track_lyrics(
track_id: uuid.UUID, lyrics: LyricsServiceDep, _: CurrentUser
) -> LyricsOut:
"""Cached lyrics for the Now Playing panel (§6.7). A miss is a normal 200
with ``status="not_found"`` — the provider (LRCLIB) is queried at most once,
then the outcome is cached."""
return LyricsOut.from_entity(await lyrics.get_lyrics(track_id))
@router.post("/{track_id}/lyrics/refetch")
async def refetch_track_lyrics(
track_id: uuid.UUID, lyrics: LyricsServiceDep, _: CurrentUser
) -> LyricsOut:
"""Force a fresh provider lookup, bypassing the cache (user-triggered)."""
return LyricsOut.from_entity(await lyrics.get_lyrics(track_id, force=True))
@router.get("/{track_id}/cover")
async def get_track_cover(
track_id: uuid.UUID,