From e6be20e50e3d87eb048fa2c092bc80dbe0a483c6 Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Tue, 23 Jun 2026 19:39:31 -0400 Subject: [PATCH] lrclib: skip tracks where both plainLyrics and syncedLyrics are null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lrclib occasionally returns records with `instrumental: false` but `plainLyrics: null` and `syncedLyrics: null` — tracks that simply have no lyrics stored yet. A concrete example is Daft Punk - Nightvision (id 249829): lrclib has the record but no lyrics text for it. Before this change, `LRCLyrics.get_text()` would return `None` (the value of `self.plain`) and `LRCLib.fetch()` would pass it straight to `Lyrics(None, ...)`, causing an `AttributeError` in `Lyrics._split_lines` when it called `None.splitlines()`. Fix both layers: - `LRCLib.fetch`: guard the `Lyrics` construction with `if lyrics := item.get_text(...)` so a null result is treated as "not found" and the backend continues searching other candidates. - `Lyrics._split_lines`: add a defensive `(self.text or "")` guard so the dataclass does not crash if it is ever constructed with `text=None` through another code path. Add a test case that exercises the null/null response path. --- beets/util/lyrics.py | 2 +- beetsplug/lyrics.py | 8 ++++---- test/plugins/test_lyrics.py | 5 +++++ 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/beets/util/lyrics.py b/beets/util/lyrics.py index 18d886e7f0..e3909394ed 100644 --- a/beets/util/lyrics.py +++ b/beets/util/lyrics.py @@ -105,7 +105,7 @@ def _split_lines(self) -> list[tuple[str, str]]: """ return [ (m[1], m[2]) if (m := self.LINE_PARTS_PAT.match(line)) else ("", "") - for line in self.text.splitlines() + for line in (self.text or "").splitlines() ] @cached_property diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 0e7c986cb8..62f85c7651 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -398,10 +398,10 @@ def fetch( for group in self.fetch_candidates(artist, title, album, length): candidates = [evaluate_item(item) for item in group] if item := self.pick_best_match(candidates): - lyrics = item.get_text(self.config["synced"].get(bool)) - return Lyrics( - lyrics, self.__class__.name, f"{self.GET_URL}/{item.id}" - ) + if lyrics := item.get_text(self.config["synced"].get(bool)): + return Lyrics( + lyrics, self.__class__.name, f"{self.GET_URL}/{item.id}" + ) return None diff --git a/test/plugins/test_lyrics.py b/test/plugins/test_lyrics.py index 1251043774..7b79dee3d4 100644 --- a/test/plugins/test_lyrics.py +++ b/test/plugins/test_lyrics.py @@ -644,6 +644,11 @@ def test_synced_config_option( "plain", id="plain by default", ), + pytest.param( + [lyrics_match(syncedLyrics=None, plainLyrics=None)], + None, + id="no lyrics when both fields are null", + ), pytest.param( [ lyrics_match(