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:
@@ -0,0 +1,97 @@
|
||||
"""Lyrics service (plan §6.7).
|
||||
|
||||
Get-or-fetch with caching: a track's lyrics are served from the DB when present;
|
||||
on a miss (or an expired ``not_found``) we ask the provider (LRCLIB) once, then
|
||||
cache the outcome. ``not_found`` is cached with a TTL so tracks that genuinely
|
||||
have no lyrics aren't looked up on every play, but can eventually be retried.
|
||||
|
||||
Degrades gracefully: if the provider is unreachable the lookup just yields a
|
||||
``not_found`` — the endpoint still returns 200 with empty lyrics, never an error.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from app.domain.entities.lyrics import Lyrics
|
||||
from app.domain.errors import NotFoundError
|
||||
from app.domain.ports import (
|
||||
AlbumRepository,
|
||||
ArtistRepository,
|
||||
LyricsProvider,
|
||||
LyricsRepository,
|
||||
TrackRepository,
|
||||
)
|
||||
|
||||
# Re-lookup a cached "not_found" only after this long — long enough not to spam
|
||||
# the provider, short enough that lyrics added upstream eventually surface.
|
||||
_NOT_FOUND_TTL = dt.timedelta(days=7)
|
||||
|
||||
_STATUS_FOUND = "found"
|
||||
_STATUS_NOT_FOUND = "not_found"
|
||||
|
||||
|
||||
class LyricsService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
lyrics: LyricsRepository,
|
||||
tracks: TrackRepository,
|
||||
artists: ArtistRepository,
|
||||
albums: AlbumRepository,
|
||||
provider: LyricsProvider,
|
||||
) -> None:
|
||||
self._lyrics = lyrics
|
||||
self._tracks = tracks
|
||||
self._artists = artists
|
||||
self._albums = albums
|
||||
self._provider = provider
|
||||
|
||||
async def get_lyrics(self, track_id: uuid.UUID, *, force: bool = False) -> Lyrics:
|
||||
"""Return cached lyrics, fetching from the provider on a miss/expiry.
|
||||
``force`` (the refetch endpoint) bypasses the cache entirely."""
|
||||
cached = await self._lyrics.get(track_id)
|
||||
if not force and cached is not None and self._is_fresh(cached):
|
||||
return cached
|
||||
return await self._fetch_and_cache(track_id)
|
||||
|
||||
def _is_fresh(self, cached: Lyrics) -> bool:
|
||||
if cached.status == _STATUS_FOUND:
|
||||
return True
|
||||
if cached.status == _STATUS_NOT_FOUND:
|
||||
return dt.datetime.now(dt.UTC) - cached.fetched_at < _NOT_FOUND_TTL
|
||||
# "pending" (never fetched) → not fresh, go fetch.
|
||||
return False
|
||||
|
||||
async def _fetch_and_cache(self, track_id: uuid.UUID) -> Lyrics:
|
||||
track = await self._tracks.get_by_id(track_id)
|
||||
if track is None:
|
||||
raise NotFoundError(f"Track {track_id} not found.")
|
||||
|
||||
artist = await self._artists.get_by_id(track.artist_id)
|
||||
album = (
|
||||
await self._albums.get_by_id(track.album_id)
|
||||
if track.album_id is not None
|
||||
else None
|
||||
)
|
||||
result = await self._provider.fetch(
|
||||
artist=artist.name if artist else "",
|
||||
title=track.title,
|
||||
album=album.title if album else None,
|
||||
duration_seconds=track.duration_seconds,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
return await self._lyrics.upsert(
|
||||
track_id=track_id,
|
||||
synced=None,
|
||||
plain=None,
|
||||
source=None,
|
||||
status=_STATUS_NOT_FOUND,
|
||||
)
|
||||
return await self._lyrics.upsert(
|
||||
track_id=track_id,
|
||||
synced=result.synced,
|
||||
plain=result.plain,
|
||||
source=result.source,
|
||||
status=_STATUS_FOUND,
|
||||
)
|
||||
Reference in New Issue
Block a user