diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 1a9a3a81a..c9e5c529d 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -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, diff --git a/docs/changelog.rst b/docs/changelog.rst index 81cd060c7..a900c9015 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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 ~~~~~~~~~ diff --git a/docs/plugins/lyrics.rst b/docs/plugins/lyrics.rst index 5c97fdea0..ea3d2d949 100644 --- a/docs/plugins/lyrics.rst +++ b/docs/plugins/lyrics.rst @@ -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 diff --git a/test/plugins/lyrics_pages.py b/test/plugins/lyrics_pages.py index 61cd13ef8..09760ba56 100644 --- a/test/plugins/lyrics_pages.py +++ b/test/plugins/lyrics_pages.py @@ -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", """ diff --git a/test/plugins/test_lyrics.py b/test/plugins/test_lyrics.py index d377b6714..e6384c4a3 100644 --- a/test/plugins/test_lyrics.py +++ b/test/plugins/test_lyrics.py @@ -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)