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
+56
View File
@@ -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,
}
]
}
}