summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorjeff <jeff@moo.dev>2023-11-05 01:39:25 -0400
committerjeff <jeff@moo.dev>2023-11-05 11:26:33 -0500
commite14982cad78a9ac9e91dda48fa45989b034c17db (patch)
treec88c8958bee8f318b1451b787cba76010c2d8a80
parentb5ff061c7261cb49a7dd90805ec37269310c07d2 (diff)
Add LRCLIB as a provider for the lyrics plugin
-rw-r--r--beetsplug/lyrics.py45
-rw-r--r--docs/changelog.rst1
-rw-r--r--docs/plugins/lyrics.rst5
-rw-r--r--test/plugins/test_lyrics.py94
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