diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 7b3980112a..fb9e5a08a1 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -26,13 +26,16 @@ from contextlib import contextmanager from functools import cached_property from html import unescape -from typing import Any, ClassVar, Iterator +from typing import TYPE_CHECKING, Any, ClassVar, Iterator from urllib.parse import urlparse import requests from typing_extensions import TypedDict from unidecode import unidecode +if TYPE_CHECKING: + from beets.library import Item, Library + try: from bs4 import BeautifulSoup @@ -269,7 +272,7 @@ def __init__(self, config, log): self._log = log self.config = config - def fetch(self, artist, title, album=None, length=None): + def fetch(self, item: Item) -> str | None: raise NotImplementedError @@ -318,28 +321,22 @@ def pick_lyrics( """ return min(data, key=lambda item: cls.get_rank(target_duration, item)) - def fetch( - self, - artist: str, - title: str, - album: str | None = None, - length: float = 0.0, - ) -> str | None: - """Fetch lyrics for the given artist, title, and album.""" + def fetch(self, item: Item) -> str | None: + """Fetch lyrics for the given item from the LRCLib API.""" params = { - "artist_name": artist, - "track_name": title, - "album_name": album, + "artist_name": item.artist, + "track_name": item.title, + "album_name": item.album, } data: list[LRCLibItem] if data := self.fetch_json(self.base_url, params=params): - item = self.pick_lyrics(length, data) + lyrics = self.pick_lyrics(item.length, data) - if self.config["synced"] and (synced := item["syncedLyrics"]): + if self.config["synced"] and (synced := lyrics["syncedLyrics"]): return synced - return item["plainLyrics"] + return lyrics["plainLyrics"] return None @@ -370,8 +367,8 @@ def build_url(self, artist, title): self._encode(title.title()), ) - def fetch(self, artist, title, album=None, length=None): - url = self.build_url(artist, title) + def fetch(self, item: Item) -> str | None: + url = self.build_url(item.artist, item.title) html = self.fetch_text(url) if "We detected that your IP is blocked" in html: @@ -412,14 +409,15 @@ def __init__(self, config, log): self.api_key = config["genius_api_key"].as_str() self.headers = {"Authorization": f"Bearer {self.api_key}"} - def fetch(self, artist, title, album=None, length=None): + def fetch(self, item: Item) -> str | None: """Fetch lyrics from genius.com Because genius doesn't allow accessing lyrics via the api, we first query the api for a url matching our artist & title, then attempt to scrape that url for the lyrics. """ - json = self._search(artist, title) + artist = item.artist + json = self._search(artist, item.title) # find a matching artist in the json for hit in json["response"]["hits"]: @@ -513,7 +511,8 @@ def build_url(self, artist, title): urllib.parse.quote_plus(unidecode(artistitle)) ) - def fetch(self, artist, title, album=None, length=None): + def fetch(self, item: Item) -> str | None: + artist, title = item.artist, item.title search_results = self.fetch_text(self.build_url(title, artist)) song_page_url = self.parse_search_results(search_results) if not song_page_url: @@ -696,7 +695,8 @@ def is_page_candidate(self, url_link, url_title, title, artist): ratio = difflib.SequenceMatcher(None, song_title, title).ratio() return ratio >= typo_ratio - def fetch(self, artist, title, album=None, length=None): + def fetch(self, item: Item) -> str | None: + artist, title = item.artist, item.title query = f"{artist} {title}" url = "https://www.googleapis.com/customsearch/v1?key=%s&cx=%s&q=%s" % ( self.api_key, @@ -966,7 +966,9 @@ def imported(self, session, task): session.lib, item, False, self.config["force"] ) - def fetch_item_lyrics(self, lib, item, write, force): + def fetch_item_lyrics( + self, lib: Library, item: Item, write: bool, force: bool + ) -> None: """Fetch and store lyrics for a single item. If ``write``, then the lyrics will also be written to the file itself. """ @@ -979,10 +981,7 @@ def fetch_item_lyrics(self, lib, item, write, force): album = item.album length = round(item.length) for artist, titles in search_pairs(item): - lyrics = [ - self.get_lyrics(artist, title, album=album, length=length) - for title in titles - ] + lyrics = [self.get_lyrics(item) for title in titles] if any(lyrics): break @@ -1011,18 +1010,18 @@ def fetch_item_lyrics(self, lib, item, write, force): item.try_write() item.store() - def get_lyrics(self, artist, title, album=None, length=None): + def get_lyrics(self, item: Item) -> str | None: """Fetch lyrics, trying each source in turn. Return a string or None if no lyrics were found. """ - self.debug("Fetching lyrics for {} - {}", artist, title) + self.debug("Fetching lyrics for {} - {}", item.artist, item.title) for backend in self.backends.values(): with backend.handle_request(): - if lyrics := backend.fetch( - artist, title, album=album, length=length - ): + if lyrics := backend.fetch(item): return lyrics + return None + def append_translation(self, text, to_lang): from xml.etree import ElementTree diff --git a/test/plugins/test_lyrics.py b/test/plugins/test_lyrics.py index a93f271deb..e143e012ab 100644 --- a/test/plugins/test_lyrics.py +++ b/test/plugins/test_lyrics.py @@ -207,9 +207,9 @@ def test_backend_source(self, backend): """Test default backends with a song known to exist in respective databases. """ - title = "Lady Madonna" - res = backend.fetch("The Beatles", title) - assert PHRASE_BY_TITLE[title] in res.lower() + item = Item(artist="The Beatles", title="Lady Madonna") + res = backend.fetch(item) + assert PHRASE_BY_TITLE[item.title] in res.lower() class TestLyricsPlugin(LyricsPluginMixin): @@ -235,7 +235,7 @@ def test_error_handling( requests_mock.get(lyrics.LRCLib.base_url, **request_kwargs) plugin = lyrics.LyricsPlugin() - assert plugin.get_lyrics("", "") is None + assert plugin.get_lyrics(Item()) is None assert caplog.messages last_log = caplog.messages[-1] assert last_log @@ -422,7 +422,7 @@ def lyrics_match(duration, synced, plain): class TestLRCLibLyrics(LyricsPluginBackendMixin): - ITEM_DURATION = 999 + ITEM_DURATION = 999.0 @pytest.fixture(scope="class") def backend_name(self): @@ -436,7 +436,8 @@ def response_data(self): def fetch_lyrics(self, backend, requests_mock, response_data): requests_mock.get(backend.base_url, json=response_data) - return partial(backend.fetch, "la", "la", "la", self.ITEM_DURATION) + item = Item(length=self.ITEM_DURATION) + return partial(backend.fetch, item) @pytest.mark.parametrize( "response_data, expected_lyrics",