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,72 @@
|
||||
"""Lyrics repository — adapter over ``AsyncSession``.
|
||||
|
||||
One cached row per track (``track_id`` unique). ``upsert`` refreshes the row and
|
||||
bumps ``fetched_at`` so the service's TTL is measured from the last fetch.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.entities.lyrics import Lyrics
|
||||
from app.infrastructure.db.models.lyrics import LyricsModel
|
||||
|
||||
|
||||
def _to_entity(row: LyricsModel) -> Lyrics:
|
||||
return Lyrics(
|
||||
track_id=row.track_id,
|
||||
synced=row.synced,
|
||||
plain=row.plain,
|
||||
source=row.source,
|
||||
status=row.status,
|
||||
fetched_at=row.fetched_at,
|
||||
)
|
||||
|
||||
|
||||
class SqlAlchemyLyricsRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def get(self, track_id: uuid.UUID) -> Lyrics | None:
|
||||
row = await self._session.scalar(
|
||||
select(LyricsModel).where(LyricsModel.track_id == track_id)
|
||||
)
|
||||
return _to_entity(row) if row is not None else None
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
*,
|
||||
track_id: uuid.UUID,
|
||||
synced: str | None,
|
||||
plain: str | None,
|
||||
source: str | None,
|
||||
status: str,
|
||||
) -> Lyrics:
|
||||
values = {
|
||||
"track_id": track_id,
|
||||
"synced": synced,
|
||||
"plain": plain,
|
||||
"source": source,
|
||||
"status": status,
|
||||
"fetched_at": func.now(),
|
||||
}
|
||||
stmt = (
|
||||
pg_insert(LyricsModel)
|
||||
.values(**values)
|
||||
.on_conflict_do_update(
|
||||
index_elements=[LyricsModel.track_id],
|
||||
set_={
|
||||
"synced": synced,
|
||||
"plain": plain,
|
||||
"source": source,
|
||||
"status": status,
|
||||
"fetched_at": func.now(),
|
||||
},
|
||||
)
|
||||
.returning(LyricsModel)
|
||||
)
|
||||
row = (await self._session.scalars(stmt)).one()
|
||||
await self._session.flush()
|
||||
return _to_entity(row)
|
||||
Reference in New Issue
Block a user