feat(subsonic): getLyricsBySongId over the native LyricsService

Adapts the cached LyricsService into the OpenSubsonic structured-lyrics shape:
synced LRC parsed into timed lines (ms), plain text as untimed fallback, empty
lyricsList when a track has none. Thin adapter — no logic duplicated. Also
parenthesize the getCoverArt except-tuple for clarity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Цвылев Александр Вадимович
2026-07-29 11:07:21 +03:00
parent 9a78cf5261
commit c5a473fddf
2 changed files with 95 additions and 4 deletions
+39 -4
View File
@@ -1,10 +1,11 @@
"""Subsonic media endpoints: stream, download, cover art.
"""Subsonic media endpoints: stream, download, cover art, lyrics.
``stream`` and ``download`` reuse :class:`StreamingService` (honouring HTTP
Range) — they return raw bytes, not the Subsonic envelope. Transcoding params
(``maxBitRate``/``format``) are accepted but ignored; the original file is served
(no in-request ffmpeg — CLAUDE.md). ``getCoverArt`` returns a placeholder until
the cover pipeline lands (the ``/api/v1`` cover endpoints are still stubs).
(no in-request ffmpeg — CLAUDE.md). ``getCoverArt`` serves the album cover (a
placeholder when there's none). ``getLyricsBySongId`` adapts the native
``LyricsService`` into the OpenSubsonic structured-lyrics shape.
"""
import base64
@@ -16,12 +17,17 @@ from fastapi.responses import Response, StreamingResponse
from app.api.covers import resolve_album_for_track, stream_cover
from app.api.deps import (
AlbumRepoDep,
ArtistRepoDep,
FileStorageDep,
LyricsServiceDep,
StreamingServiceDep,
SubsonicFormat,
SubsonicUser,
TrackRepoDep,
)
from app.api.rest.envelope import subsonic_response
from app.api.rest.ids import IdKind, decode_track, parse
from app.api.rest.serializers import structured_lyrics
from app.domain.entities.album import Album
from app.domain.errors import NotFoundError, StorageError
@@ -99,6 +105,35 @@ async def get_cover_art(
if album is not None and album.cover_path:
try:
return await stream_cover(storage, album.cover_path)
except NotFoundError, StorageError:
except (NotFoundError, StorageError):
pass
return Response(content=_PLACEHOLDER_PNG, media_type="image/png")
@router.api_route("/getLyricsBySongId", methods=["GET", "POST"])
@router.api_route("/getLyricsBySongId.view", methods=["GET", "POST"])
async def get_lyrics_by_song_id(
_user: SubsonicUser,
fmt: SubsonicFormat,
lyrics_service: LyricsServiceDep,
track_repo: TrackRepoDep,
artist_repo: ArtistRepoDep,
id: Annotated[str, Query()],
) -> Response:
# OpenSubsonic structured lyrics over the native LyricsService (§6.7). A miss
# is a normal empty ``lyricsList`` — the service degrades to not_found rather
# than raising, so clients get 200 either way.
track_id = decode_track(id)
track = await track_repo.get_by_id(track_id)
if track is None:
raise NotFoundError("Song not found.")
lyrics = await lyrics_service.get_lyrics(track_id)
artist = await artist_repo.get_by_id(track.artist_id)
return subsonic_response(
structured_lyrics(
lyrics,
display_artist=artist.name if artist is not None else "",
display_title=track.title,
),
fmt=fmt,
)