diff options
author | jeff <jeff@moo.dev> | 2023-11-05 01:39:25 -0400 |
---|---|---|
committer | jeff <jeff@moo.dev> | 2023-11-05 11:26:33 -0500 |
commit | e14982cad78a9ac9e91dda48fa45989b034c17db (patch) | |
tree | c88c8958bee8f318b1451b787cba76010c2d8a80 | |
parent | b5ff061c7261cb49a7dd90805ec37269310c07d2 (diff) |
Add LRCLIB as a provider for the lyrics plugin
-rw-r--r-- | beetsplug/lyrics.py | 45 | ||||
-rw-r--r-- | docs/changelog.rst | 1 | ||||
-rw-r--r-- | docs/plugins/lyrics.rst | 5 | ||||
-rw-r--r-- | test/plugins/test_lyrics.py | 94 |
4 files changed, 134 insertions, 11 deletions
diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 183d68ed8..5c8ba3543 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -290,10 +290,31 @@ class Backend: self._log.debug("failed to fetch: {0} ({1})", url, r.status_code) return None - def fetch(self, artist, title): + def fetch(self, artist, title, album=None, length=None): raise NotImplementedError() +class LRCLib(Backend): + base_url = "https://lrclib.net/api/get" + + def fetch(self, artist, title, album=None, length=None): + params = { + "artist_name": artist, + "track_name": title, + "album_name": album, + "duration": length, + } + + try: + response = requests.get(self.base_url, params=params) + data = response.json() + except (requests.RequestException, json.decoder.JSONDecodeError) as exc: + self._log.debug("LRCLib API request failed: {0}", exc) + return None + + return data.get("syncedLyrics") or data.get("plainLyrics") + + class MusiXmatch(Backend): REPLACEMENTS = { r"\s+": "-", @@ -313,7 +334,7 @@ class MusiXmatch(Backend): return super()._encode(s) - def fetch(self, artist, title): + def fetch(self, artist, title, album=None, length=None): url = self.build_url(artist, title) html = self.fetch_url(url) @@ -361,7 +382,7 @@ class Genius(Backend): "User-Agent": USER_AGENT, } - def fetch(self, artist, title): + def fetch(self, artist, title, album=None, length=None): """Fetch lyrics from genius.com Because genius doesn't allow accessing lyrics via the api, @@ -473,7 +494,7 @@ class Tekstowo(Backend): BASE_URL = "http://www.tekstowo.pl" URL_PATTERN = BASE_URL + "/wyszukaj.html?search-title=%s&search-artist=%s" - def fetch(self, artist, title): + def fetch(self, artist, title, album=None, length=None): url = self.build_url(title, artist) search_results = self.fetch_url(url) if not search_results: @@ -706,7 +727,7 @@ class Google(Backend): ratio = difflib.SequenceMatcher(None, song_title, title).ratio() return ratio >= typo_ratio - def fetch(self, artist, title): + def fetch(self, artist, title, album=None, length=None): query = f"{artist} {title}" url = "https://www.googleapis.com/customsearch/v1?key=%s&cx=%s&q=%s" % ( self.api_key, @@ -750,12 +771,13 @@ class Google(Backend): class LyricsPlugin(plugins.BeetsPlugin): - SOURCES = ["google", "musixmatch", "genius", "tekstowo"] + SOURCES = ["google", "musixmatch", "genius", "tekstowo", "lrclib"] SOURCE_BACKENDS = { "google": Google, "musixmatch": MusiXmatch, "genius": Genius, "tekstowo": Tekstowo, + "lrclib": LRCLib, } def __init__(self): @@ -1019,8 +1041,13 @@ class LyricsPlugin(plugins.BeetsPlugin): return lyrics = None + album = item.album + length = round(item.length) for artist, titles in search_pairs(item): - lyrics = [self.get_lyrics(artist, title) for title in titles] + lyrics = [ + self.get_lyrics(artist, title, album=album, length=length) + for title in titles + ] if any(lyrics): break @@ -1049,12 +1076,12 @@ class LyricsPlugin(plugins.BeetsPlugin): item.try_write() item.store() - def get_lyrics(self, artist, title): + def get_lyrics(self, artist, title, album=None, length=None): """Fetch lyrics, trying each source in turn. Return a string or None if no lyrics were found. """ for backend in self.backends: - lyrics = backend.fetch(artist, title) + lyrics = backend.fetch(artist, title, album=album, length=length) if lyrics: self._log.debug( "got lyrics from backend: {0}", backend.__class__.__name__ diff --git a/docs/changelog.rst b/docs/changelog.rst index 52c681dc0..9f51921eb 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -141,6 +141,7 @@ New features: but no thumbnail is provided by CAA. We now fallback to the raw image. * :doc:`/plugins/advancedrewrite`: Add an advanced version of the `rewrite` plugin which allows to replace fields based on a given library query. +* :doc:`/plugins/lyrics`: Add LRCLIB as a new lyrics provider. Bug fixes: diff --git a/docs/plugins/lyrics.rst b/docs/plugins/lyrics.rst index 90c455e15..68ab86301 100644 --- a/docs/plugins/lyrics.rst +++ b/docs/plugins/lyrics.rst @@ -2,11 +2,12 @@ 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`_, +Namely, the current version of the plugin uses `Genius.com`_, `Tekstowo.pl`_, `LRCLIB`_ and, optionally, the Google custom search API. .. _Genius.com: https://genius.com/ .. _Tekstowo.pl: https://www.tekstowo.pl/ +.. _LRCLIB: https://lrclib.net/ Fetch Lyrics During Import @@ -58,7 +59,7 @@ configuration file. The available options are: sources known to be scrapeable. - **sources**: List of sources to search for lyrics. An asterisk ``*`` expands to all available sources. - Default: ``google genius tekstowo``, i.e., all the available sources. The + Default: ``google genius tekstowo lrclib``, i.e., all the available sources. The ``google`` source will be automatically deactivated if no ``google_API_key`` is setup. The ``google``, ``genius``, and ``tekstowo`` sources will only be enabled if diff --git a/test/plugins/test_lyrics.py b/test/plugins/test_lyrics.py index e54223786..508400146 100644 --- a/test/plugins/test_lyrics.py +++ b/test/plugins/test_lyrics.py @@ -23,6 +23,7 @@ from test import _common from unittest.mock import MagicMock, patch import confuse +import requests from beets import logging from beets.library import Item @@ -34,6 +35,7 @@ raw_backend = lyrics.Backend({}, log) google = lyrics.Google(MagicMock(), log) genius = lyrics.Genius(MagicMock(), log) tekstowo = lyrics.Tekstowo(MagicMock(), log) +lrclib = lyrics.LRCLib(MagicMock(), log) class LyricsPluginTest(unittest.TestCase): @@ -677,6 +679,98 @@ class TekstowoIntegrationTest(TekstowoBaseTest, LyricsAssertions): self.assertEqual(lyrics, None) +# test LRCLib backend + + +class LRCLibLyricsTest(unittest.TestCase): + def setUp(self): + self.plugin = lyrics.LyricsPlugin() + lrclib.config = self.plugin.config + + @patch("beetsplug.lyrics.requests.get") + def test_fetch_synced_lyrics(self, mock_get): + mock_response = { + "syncedLyrics": "[00:00.00] la la la", + "plainLyrics": "la la la", + } + mock_get.return_value.json.return_value = mock_response + mock_get.return_value.status_code = 200 + + lyrics = lrclib.fetch("la", "la", "la", 999) + + self.assertEqual(lyrics, mock_response["syncedLyrics"]) + + @patch("beetsplug.lyrics.requests.get") + def test_fetch_plain_lyrics(self, mock_get): + mock_response = { + "syncedLyrics": "", + "plainLyrics": "la la la", + } + mock_get.return_value.json.return_value = mock_response + mock_get.return_value.status_code = 200 + + lyrics = lrclib.fetch("la", "la", "la", 999) + + self.assertEqual(lyrics, mock_response["plainLyrics"]) + + @patch("beetsplug.lyrics.requests.get") + def test_fetch_not_found(self, mock_get): + mock_response = { + "statusCode": 404, + "error": "Not Found", + "message": "Failed to find specified track", + } + mock_get.return_value.json.return_value = mock_response + mock_get.return_value.status_code = 404 + + lyrics = lrclib.fetch("la", "la", "la", 999) + + self.assertIsNone(lyrics) + + @patch("beetsplug.lyrics.requests.get") + def test_fetch_exception(self, mock_get): + mock_get.side_effect = requests.RequestException + + lyrics = lrclib.fetch("la", "la", "la", 999) + + self.assertIsNone(lyrics) + + +class LRCLibIntegrationTest(LyricsAssertions): + def setUp(self): + self.plugin = lyrics.LyricsPlugin() + lrclib.config = self.plugin.config + + @unittest.skipUnless( + os.environ.get("INTEGRATION_TEST", "0") == "1", + "integration testing not enabled", + ) + def test_track_with_lyrics(self): + lyrics = lrclib.fetch("Boy in Space", "u n eye", "Live EP", 160) + self.assertLyricsContentOk("u n eye", lyrics) + + @unittest.skipUnless( + os.environ.get("INTEGRATION_TEST", "0") == "1", + "integration testing not enabled", + ) + def test_instrumental_track(self): + lyrics = lrclib.fetch( + "Kelly Bailey", + "Black Mesa Inbound", + "Half Life 2 Soundtrack", + 134, + ) + self.assertIsNone(lyrics) + + @unittest.skipUnless( + os.environ.get("INTEGRATION_TEST", "0") == "1", + "integration testing not enabled", + ) + def test_nonexistent_track(self): + lyrics = lrclib.fetch("blah", "blah", "blah", 999) + self.assertIsNone(lyrics) + + # test utilities |