8271de34eb
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>
98 lines
3.3 KiB
Python
98 lines
3.3 KiB
Python
"""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,
|
|
)
|