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:
+39
-4
@@ -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,
|
||||
)
|
||||
|
||||
@@ -6,10 +6,17 @@ JSON equivalents). No business logic — they only reshape and rename.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.api.rest.ids import encode_album, encode_artist, encode_track
|
||||
from app.domain.entities import Album, Artist, Track
|
||||
from app.domain.entities.lyrics import Lyrics
|
||||
|
||||
# One LRC timecode: ``[mm:ss.xx]`` / ``[mm:ss.xxx]`` (fraction optional). A line
|
||||
# may carry several (the same words repeat at multiple times); metadata tags like
|
||||
# ``[ar:..]`` don't match, so they're ignored.
|
||||
_LRC_TAG_RE = re.compile(r"\[(\d+):(\d{1,2})(?:[.:](\d{1,3}))?\]")
|
||||
|
||||
# Suffix → MIME, for the ``contentType``/``suffix`` song attributes. A
|
||||
# presentation detail (mirrors StreamingService's content-type negotiation).
|
||||
@@ -90,3 +97,52 @@ def song_dict(
|
||||
"type": "music",
|
||||
"isVideo": False,
|
||||
}
|
||||
|
||||
|
||||
def _parse_lrc(synced: str) -> list[dict[str, Any]]:
|
||||
"""LRC text → OpenSubsonic ``line`` dicts (``start`` in ms, ``value`` text),
|
||||
ordered by time. Lines with no timecode (blank lines, metadata tags) drop out;
|
||||
a timecode carrying several stamps yields one line per stamp."""
|
||||
lines: list[tuple[int, str]] = []
|
||||
for raw in synced.splitlines():
|
||||
stamps = list(_LRC_TAG_RE.finditer(raw))
|
||||
if not stamps:
|
||||
continue
|
||||
text = _LRC_TAG_RE.sub("", raw).strip()
|
||||
for m in stamps:
|
||||
minutes, seconds = int(m.group(1)), int(m.group(2))
|
||||
# LRC fractions are centiseconds (2 digits) or ms (3); pad to ms.
|
||||
ms = int((m.group(3) or "0").ljust(3, "0")[:3])
|
||||
lines.append(((minutes * 60 + seconds) * 1000 + ms, text))
|
||||
lines.sort(key=lambda pair: pair[0])
|
||||
return [{"start": start, "value": text} for start, text in lines]
|
||||
|
||||
|
||||
def structured_lyrics(
|
||||
lyrics: Lyrics, *, display_artist: str, display_title: str
|
||||
) -> dict[str, Any]:
|
||||
"""OpenSubsonic ``getLyricsBySongId`` payload. Prefers synced (LRC) lines and
|
||||
falls back to plain text; an empty ``lyricsList`` when the track has none."""
|
||||
lines: list[dict[str, Any]] = []
|
||||
synced = False
|
||||
if lyrics.synced:
|
||||
lines = _parse_lrc(lyrics.synced)
|
||||
synced = bool(lines)
|
||||
if not lines and lyrics.plain:
|
||||
lines = [{"value": line} for line in lyrics.plain.splitlines()]
|
||||
if not lines:
|
||||
return {"lyricsList": {}}
|
||||
return {
|
||||
"lyricsList": {
|
||||
"structuredLyrics": [
|
||||
{
|
||||
"displayArtist": display_artist,
|
||||
"displayTitle": display_title,
|
||||
"lang": "xxx",
|
||||
"offset": 0,
|
||||
"synced": synced,
|
||||
"line": lines,
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user