diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 6f53a15ad9..46a6e53c4b 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -255,7 +255,7 @@ def __init__(self, config, log): def fetch( self, artist: str, title: str, album: str, length: int - ) -> str | None: + ) -> tuple[str, str] | None: raise NotImplementedError @@ -266,6 +266,7 @@ class LRCLyrics: DURATION_DIFF_TOLERANCE = 0.05 target_duration: float + id: int duration: float instrumental: bool plain: str @@ -281,6 +282,7 @@ def make( ) -> LRCLyrics: return cls( target_duration, + candidate["id"], candidate["duration"] or 0.0, candidate["instrumental"], candidate["plainLyrics"], @@ -360,18 +362,20 @@ def pick_best_match(cls, lyrics: Iterable[LRCLyrics]) -> LRCLyrics | None: def fetch( self, artist: str, title: str, album: str, length: int - ) -> str | None: + ) -> tuple[str, str] | None: """Fetch lyrics text for the given song data.""" fetch = partial(self.fetch_candidates, artist, title, album, length) make = partial(LRCLyrics.make, target_duration=length) pick = self.pick_best_match try: - return next( + item = next( filter(None, map(pick, (map(make, x) for x in fetch()))) - ).get_text(self.config["synced"]) + ) except StopIteration: return None + return item.get_text(self.config["synced"]), f"{self.GET_URL}/{item.id}" + class DirectBackend(Backend): """A backend for fetching lyrics directly.""" @@ -407,7 +411,7 @@ def encode(cls, text: str) -> str: return quote(unidecode(text)) - def fetch(self, artist: str, title: str, *_) -> str | None: + def fetch(self, artist: str, title: str, *_) -> tuple[str, str] | None: url = self.build_url(artist, title) html = self.fetch_text(url) @@ -429,7 +433,7 @@ def fetch(self, artist: str, title: str, *_) -> str | None: # sometimes there are non-existent lyrics with some content if "Lyrics | Musixmatch" in lyrics: return None - return lyrics + return lyrics, url class Html: @@ -530,13 +534,13 @@ def get_results(self, artist: str, title: str) -> Iterable[SearchResult]: if check_match(candidate): yield candidate - def fetch(self, artist: str, title: str, *_) -> str | None: + def fetch(self, artist: str, title: str, *_) -> tuple[str, str] | None: """Fetch lyrics for the given artist and title.""" for result in self.get_results(artist, title): if (html := self.fetch_text(result.url)) and ( lyrics := self.scrape(html) ): - return lyrics + return lyrics, result.url return None @@ -594,11 +598,15 @@ class Tekstowo(SoupMixin, DirectBackend): def encode(cls, text: str) -> str: return cls.non_alpha_to_underscore(unidecode(text.lower())) - def fetch(self, artist: str, title: str, *_) -> str | None: + def fetch(self, artist: str, title: str, *_) -> tuple[str, str] | None: + url = self.build_url(artist, title) # We are expecting to receive a 404 since we are guessing the URL. # Thus suppress the error so that it does not end up in the logs. with suppress(NotFoundError): - return self.scrape(self.fetch_text(self.build_url(artist, title))) + if lyrics := self.scrape(self.fetch_text(url)): + return lyrics, url + + return None return None @@ -1004,8 +1012,9 @@ def get_lyrics(self, artist: str, title: str, *args) -> str | None: self.info("Fetching lyrics for {} - {}", artist, title) for backend in self.backends: with backend.handle_request(): - if lyrics := backend.fetch(artist, title, *args): - return lyrics + if lyrics_info := backend.fetch(artist, title, *args): + lyrics, url = lyrics_info + return f"{lyrics}\n\nSource: {url}" return None diff --git a/test/plugins/test_lyrics.py b/test/plugins/test_lyrics.py index e2085106d6..be1e4c2bb5 100644 --- a/test/plugins/test_lyrics.py +++ b/test/plugins/test_lyrics.py @@ -279,11 +279,12 @@ def _patch_google_search(self, requests_mock, lyrics_page): def test_backend_source(self, lyrics_plugin, lyrics_page: LyricsPage): """Test parsed lyrics from each of the configured lyrics pages.""" - lyrics = lyrics_plugin.get_lyrics( + lyrics_info = lyrics_plugin.get_lyrics( lyrics_page.artist, lyrics_page.track_title, "", 186 ) - assert lyrics + assert lyrics_info + lyrics, _ = lyrics_info.split("\n\nSource: ") assert lyrics == lyrics_page.lyrics @@ -396,6 +397,7 @@ def test_scrape(self, backend, lyrics_html, expecting_lyrics): def lyrics_match(**overrides): return { + "id": 1, "instrumental": False, "duration": LYRICS_DURATION, "syncedLyrics": "synced", @@ -424,7 +426,9 @@ def fetch_lyrics(self, backend, requests_mock, response_data): [({"synced": True}, "synced"), ({"synced": False}, "plain")], ) def test_synced_config_option(self, fetch_lyrics, expected_lyrics): - assert fetch_lyrics() == expected_lyrics + lyrics, _ = fetch_lyrics() + + assert lyrics == expected_lyrics @pytest.mark.parametrize( "response_data, expected_lyrics", @@ -471,4 +475,10 @@ def test_synced_config_option(self, fetch_lyrics, expected_lyrics): ) @pytest.mark.parametrize("plugin_config", [{"synced": True}]) def test_fetch_lyrics(self, fetch_lyrics, expected_lyrics): - assert fetch_lyrics() == expected_lyrics + lyrics_info = fetch_lyrics() + if lyrics_info is None: + assert expected_lyrics is None + else: + lyrics, _ = fetch_lyrics() + + assert lyrics == expected_lyrics