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,41 @@
|
||||
"""Lyrics value objects (plan §6.7).
|
||||
|
||||
``Lyrics`` is the cached row for a track; ``LyricsResult`` is what a provider
|
||||
(LRCLIB) returns for a lookup. Both cross the domain boundary — no framework
|
||||
imports. Status values mirror ``LyricsStatus`` in the ORM enum ("found" /
|
||||
"not_found" / "pending") but are kept as plain strings here so the domain stays
|
||||
independent of the persistence layer.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LyricsResult:
|
||||
"""A provider hit: synced (timestamped LRC) and/or plain text."""
|
||||
|
||||
synced: str | None
|
||||
plain: str | None
|
||||
source: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Lyrics:
|
||||
"""Cached lyrics for one track. ``status`` is ``found`` / ``not_found`` /
|
||||
``pending``; ``not_found`` is cached too (with a TTL in the service) so a
|
||||
track with no lyrics doesn't hammer the provider on every play."""
|
||||
|
||||
track_id: uuid.UUID
|
||||
synced: str | None
|
||||
plain: str | None
|
||||
source: str | None
|
||||
status: str
|
||||
fetched_at: dt.datetime
|
||||
|
||||
@property
|
||||
def has_lyrics(self) -> bool:
|
||||
return self.status == "found" and (
|
||||
self.synced is not None or self.plain is not None
|
||||
)
|
||||
@@ -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: ...
|
||||
|
||||
Reference in New Issue
Block a user