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
+31
View File
@@ -29,6 +29,7 @@ from app.domain.entities import (
SubsonicCredentials,
User,
)
from app.domain.entities.lyrics import Lyrics, LyricsResult
from app.domain.entities.settings import UserSettings
from app.domain.entities.track import Artist, Track
from app.domain.sources import DownloadResult, RawMetadata, SearchResult, SourceFile, SourceInfo
@@ -494,3 +495,33 @@ class CoverArtProvider(Protocol):
def is_available(self) -> bool: ...
async def fetch_release_group(self, release_group_mbid: str) -> CoverArt | None: ...
class LyricsProvider(Protocol):
"""Fetches lyrics from an external database (LRCLIB) by artist/title/album/
duration. Returns a hit or ``None`` (no match / service down), never raising."""
async def fetch(
self,
*,
artist: str,
title: str,
album: str | None,
duration_seconds: int | None,
) -> LyricsResult | None: ...
class LyricsRepository(Protocol):
"""Cached lyrics, one row per track. ``upsert`` also caches a ``not_found``
(empty text) so misses aren't re-fetched until the service's TTL lapses."""
async def get(self, track_id: uuid.UUID) -> Lyrics | None: ...
async def upsert(
self,
*,
track_id: uuid.UUID,
synced: str | None,
plain: str | None,
source: str | None,
status: str,
) -> Lyrics: ...