lyrics: add lrcmux backend

This commit is contained in:
f1nn
2026-07-15 16:10:42 +02:00
parent 361405a42d
commit 0d65a46bbc
5 changed files with 152 additions and 6 deletions

View File

@@ -897,6 +897,42 @@ class Translator(LyricsRequestHandler):
return lyrics
class LRCMux(Backend):
"""Fetch lyrics from the lrcmux API."""
DEFAULT_URL = "https://api.lrcmux.dev"
@cached_property
def url(self) -> str:
base = self.config["lrcmux"]["url"].get(str)
return f"{base.rstrip('/')}/get"
def fetch(
self, artist: str, title: str, album: str, length: int
) -> Lyrics | None:
synced = self.config["synced"].get(bool)
sources = self.config["lrcmux"]["sources"].as_str_seq()
params: JSONDict = {
"artist": artist,
"title": title,
"duration": int(length),
"format": "lrc" if synced else "txt",
"level": "line" if synced else "none",
}
if album:
params["album"] = album
if sources:
params["sources"] = ",".join(sources)
full_url = self.format_url(self.url, params)
with suppress(HTTPNotFoundError):
if text := self.get_text(self.url, params=params).strip():
return Lyrics(text, self.__class__.name, full_url)
return None
@dataclass
class RestFiles:
# The content for the base index.rst generated in ReST mode.
@@ -986,7 +1022,7 @@ class RestFiles:
BACKEND_BY_NAME = {
b.name: b for b in [LRCLib, Google, Genius, Tekstowo, MusiXmatch]
b.name: b for b in [LRCLib, Google, Genius, Tekstowo, MusiXmatch, LRCMux]
}
@@ -1035,6 +1071,7 @@ class LyricsPlugin(LyricsRequestHandler, plugins.BeetsPlugin):
"Ryq93pUGm8bM6eUWwD_M3NOFFDAtp2yEE7W"
"76V-uFL5jks5dNvcGCdarqFjDhP9c"
),
"lrcmux": {"url": LRCMux.DEFAULT_URL, "sources": []},
"fallback": None,
"force": False,
"keep_synced": False,

View File

@@ -47,6 +47,8 @@ New features
names are now consistent across both files, long-standing mismatches between
them have been resolved, and entries align with the built-in alias patterns.
:bug:`6466`
- :doc:`plugins/lyrics`: Add ``lrcmux`` backend, which aggregates lyrics from
various other sources.
Bug fixes
~~~~~~~~~

View File

@@ -3,12 +3,14 @@ Lyrics Plugin
The ``lyrics`` plugin fetches and stores song lyrics from databases on the Web.
Namely, the current version of the plugin uses Genius.com_, Tekstowo.pl_,
LRCLIB_ and, optionally, the Google Custom Search API.
LRCLIB_, lrcmux_ and, optionally, the Google Custom Search API.
.. _genius.com: https://genius.com/
.. _lrclib: https://lrclib.net/
.. _lrcmux: https://lrcmux.dev/
.. _tekstowo.pl: https://www.tekstowo.pl/
Install
@@ -62,9 +64,12 @@ Default configuration:
keep_synced: no
google_API_key: null
google_engine_ID: 009217259823014548361:lndtuqkycfu
lrcmux:
url: https://api.lrcmux.dev
sources: []
print: no
rest_directory: null
sources: [lrclib, google, genius]
sources: [lrclib, google, genius, lrcmux]
synced: no
The available options are:
@@ -111,6 +116,15 @@ The available options are:
- **google_engine_ID**: The custom search engine to use. Default: The `beets
custom search engine`_, which gathers an updated list of sources known to be
scrapeable.
- **lrcmux**:
- **url**: Base URL of the lrcmux_ instance to use. Override this to point at
a self-hosted instance.
- **sources**: List of lrcmux provider IDs to restrict the search to, e.g.
``[ytmusic, kugou]``. Prefix an entry with ``!`` to exclude that provider
instead, e.g. ``["!musixmatch"]``. Leave empty (the default) to use all
providers.
- **print**: Print lyrics to the console.
- **rest_directory**: The directory to which reStructuredText_ (ReST) rendered
lyric documents will be output. See :ref:`rendering-lyrics`.
@@ -119,7 +133,7 @@ The available options are:
deactivated if no ``google_API_key`` is setup. By default, ``musixmatch`` and
``tekstowo`` are excluded because they block the beets User-Agent.
- **synced**: Prefer synced lyrics over plain lyrics if a source offers them.
Currently ``lrclib`` is the only source that provides them. Using this option,
``lrclib`` and ``lrcmux`` both provide synced lyrics. Using this option,
existing synced lyrics are not replaced by newly fetched plain lyrics (even
when ``force`` is enabled). To allow that replacement, disable ``synced``.
When synced lyrics are written to an ID3-tagged file (MP3, AIFF, etc.) the

View File

@@ -41,11 +41,18 @@ class LyricsPage(NamedTuple):
@property
def source(self) -> str:
return self.root_url.replace("www.", "").split(".")[0]
return (
self.root_url.replace("www.", "").replace("api.", "").split(".")[0]
)
@property
def backend(self) -> str:
if (source := self.source) in {"genius", "tekstowo", "lrclib"}:
if (source := self.source) in {
"genius",
"tekstowo",
"lrclib",
"lrcmux",
}:
return source
return "google"
@@ -300,6 +307,35 @@ lyrics_pages = [
[02:06.04]
""",
),
LyricsPage.make(
"https://api.lrcmux.dev/get?artist=The+Beatles&title=Lady+Madonna&duration=186&format=lrc&level=line&sources=ytmusic",
"""
[00:08.71] Lady Madonna, children at your feet
[00:13.08] Wonder how you manage to make ends meet
[00:17.43] Who finds the money when you pay the rent?
[00:21.92] Did you think that money was Heaven-sent?
[00:26.21] Friday night arrives without a suitcase
[00:30.54] Sunday morning creeping like a nun
[00:35.07] Monday's child has learned to tie his bootlace
[00:39.37] See how they run
[00:43.80] Lady Madonna, baby at your breast
[00:48.23] Wonders how you manage to feed the rest
[00:55.32]
[01:01.30] Pa-pa-pa-pa, pa-pa-pa-pa-pa
[01:05.68] Pa-pa-pa-pa, pa-pa-pa-pa-pa-pa-pa
[01:09.86] Pa-pa-pa-pa, pa-pa-pa-pa-pa
[01:14.40] See how they run
[01:18.81] Lady Madonna lying on the bed
[01:23.25] Listen to the music playing in your head
[01:30.21]
[01:36.40] Tuesday afternoon is never ending
[01:40.78] Wednesday morning papers didn't come
[01:45.06] Thursday night you stocking needed mending
[01:49.48] See how they run
[01:53.90] Lady Madonna, children at your feet
[01:58.42] Wonder how you manage to make ends meet
""",
),
LyricsPage.make(
"https://www.lyricsmania.com/lady_madonna_lyrics_the_beatles.html",
"""

View File

@@ -431,6 +431,11 @@ class TestLyricsSources(LyricsBackendTest):
}
requests_mock.get(lyrics.Google.SEARCH_URL, json=data)
@pytest.fixture(autouse=True)
def _set_lrcmux_sources(self, lyrics_plugin, backend_name):
if backend_name == "lrcmux":
lyrics_plugin.config["lrcmux"]["sources"] = ["ytmusic"]
def test_backend_source(
self, monkeypatch, lyrics_plugin, lyrics_page: LyricsPage
):
@@ -681,6 +686,58 @@ class TestLRCLibLyrics(LyricsBackendTest):
assert lyrics.text == expected_lyrics
class TestLRCMuxLyrics(LyricsBackendTest):
SYNCED = "[00:00.00] synced"
PLAIN = "plain"
@pytest.fixture(scope="class")
def backend_name(self):
return "lrcmux"
@pytest.mark.parametrize(
"plugin_config, mocked_text, expected_format, expected_level",
[
pytest.param({"synced": True}, SYNCED, "lrc", "line", id="synced"),
pytest.param({"synced": False}, PLAIN, "txt", "none", id="plain"),
pytest.param(
{"synced": True, "lrcmux": {"sources": ["ytmusic"]}},
SYNCED,
"lrc",
"line",
id="synced-sources",
),
],
)
def test_fetch_lyrics(
self,
backend,
requests_mock,
mocked_text,
expected_format,
expected_level,
plugin_config,
):
requests_mock.get(backend.url, text=mocked_text)
result = backend.fetch("la", "la", "la", 0)
assert result
assert result.text == mocked_text
assert result.backend == "lrcmux"
assert f"format={expected_format}" in result.url
assert f"level={expected_level}" in result.url
if sources := (plugin_config.get("lrcmux") or {}).get("sources"):
assert f"sources={','.join(sources)}" in result.url
@pytest.mark.parametrize("plugin_config", [{}])
def test_not_found(self, backend, requests_mock):
requests_mock.get(backend.url, status_code=HTTPStatus.NOT_FOUND)
assert backend.fetch("la", "la", "", 0) is None
@pytest.mark.parametrize("plugin_config", [{}])
def test_empty_response(self, backend, requests_mock):
requests_mock.get(backend.url, text="")
assert backend.fetch("la", "la", "", 0) is None
@pytest.mark.requires_import("langdetect")
class TestTranslation:
@pytest.fixture(autouse=True)