"""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, )