Files
mcma-backend/app/application/lyrics_service.py
T
Цвылев Александр Вадимович 8271de34eb 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>
2026-07-28 20:58:28 +03:00

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