From c5a473fddf35b147b83ea77e2fc4d6fd9e509cb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A6=D0=B2=D1=8B=D0=BB=D0=B5=D0=B2=20=D0=90=D0=BB=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20=D0=92=D0=B0=D0=B4=D0=B8?= =?UTF-8?q?=D0=BC=D0=BE=D0=B2=D0=B8=D1=87?= Date: Wed, 29 Jul 2026 11:07:21 +0300 Subject: [PATCH] feat(subsonic): getLyricsBySongId over the native LyricsService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/api/rest/media.py | 43 +++++++++++++++++++++++++--- app/api/rest/serializers.py | 56 +++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/app/api/rest/media.py b/app/api/rest/media.py index db8df10..cb0fc00 100644 --- a/app/api/rest/media.py +++ b/app/api/rest/media.py @@ -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, + ) diff --git a/app/api/rest/serializers.py b/app/api/rest/serializers.py index c6b7aaa..5dc2ede 100644 --- a/app/api/rest/serializers.py +++ b/app/api/rest/serializers.py @@ -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, + } + ] + } + }