mirror of
https://github.com/beetbox/beets.git
synced 2026-07-16 14:07:40 -04:00
lyrics: add lrcmux backend
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
~~~~~~~~~
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user