Add LRCLIB as a provider for the lyrics plugin

This commit is contained in:
jeff 2023-11-05 01:39:25 -04:00
parent b5ff061c72
commit e14982cad7
4 changed files with 134 additions and 11 deletions

View file

@ -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__

View file

@ -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:

View file

@ -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

View file

@ -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