Repository: beetbox/beets

Issue:
beets can't fetch lyrics from lrclib.net
I want to fetch synced lyrics from lrclib but beet can't find them even if i can find them manually on lrclib.
Here's my config:
```
plugins: fromfilename lastgenre lyrics
lyrics:
  sources: lrclib
  synced: yes

```



Discussion:
Can you please fill out a bit more of the bug report template? It helps us work out what's going on. Specifically:

* Verbose command output (with `-vv`)
* beets version
* Output of `beet config`

The full template can be found here: https://github.com/beetbox/beets/blob/master/.github/ISSUE_TEMPLATE/bug-report.md?plain=1
Not OP, but here is my info:

---
name: "\U0001F41B Bug report"
about: Lyrics not found when using lrclib

---

<!--
I tried getting synced lyrics from lrclib.net.  I get the following error message:

lyrics: lyrics not found: Metallica - S&M - 09. Hero of the Day (4:44)
-->

### Problem

Running this command in verbose (`-vv`) mode:

```sh
$ beet -vv lyrics -f Metallica Load
```
user configuration: /home/user/.config/beets/config.yaml
data directory: /home/user/.config/beets
plugin paths: 
inline: adding item field multidisc
inline: adding item field lmultidisc
Sending event: pluginload
library database: /home/user/.config/beets/library.db
library directory: /home/user/Music
Sending event: library_opened
lyrics: lyrics not found: Metallica - Load - 01. Ain’t My Bitch (5:04)
lyrics: lyrics not found: Metallica - Load - 02. 2 × 4 (5:28)
lyrics: lyrics not found: Metallica - Load - 03. The House Jack Built (6:38)
lyrics: lyrics not found: Metallica - Load - 04. Until It Sleeps (4:29)
lyrics: lyrics not found: Metallica - Load - 05. King Nothing (5:28)
lyrics: lyrics not found: Metallica - Load - 06. Hero of the Day (4:21)
lyrics: lyrics not found: Metallica - Load - 07. Bleeding Me (8:18)
lyrics: lyrics not found: Metallica - Load - 08. Cure (4:54)
lyrics: lyrics not found: Metallica - Load - 09. Poor Twisted Me (4:00)
lyrics: lyrics not found: Metallica - Load - 10. Wasting My Hate (3:57)
lyrics: lyrics not found: Metallica - Load - 11. Mama Said (5:19)
lyrics: lyrics not found: Metallica - Load - 12. Thorn Within (5:51)
lyrics: lyrics not found: Metallica - Load - 13. Ronnie (5:17)
lyrics: lyrics not found: Metallica - Load - 14. The Outlaw Torn (9:48)
lyrics: lyrics not found: Metallica - Reload - 01. Fuel (4:29)
lyrics: lyrics not found: Metallica - Reload - 02. The Memory Remains (4:39)
lyrics: lyrics not found: Metallica - Reload - 03. Devil’s Dance (5:18)
lyrics: lyrics not found: Metallica - Reload - 04. The Unforgiven II (6:36)
lyrics: lyrics not found: Metallica - Reload - 05. Better Than You (5:21)
lyrics: lyrics not found: Metallica - Reload - 06. Slither (5:13)
lyrics: lyrics not found: Metallica - Reload - 07. Carpe Diem Baby (6:12)
lyrics: lyrics not found: Metallica - Reload - 08. Bad Seed (4:05)
lyrics: lyrics not found: Metallica - Reload - 09. Where the Wild Things Are (6:54)
lyrics: lyrics not found: Metallica - Reload - 10. Prince Charming (6:05)
lyrics: lyrics not found: Metallica - Reload - 11. Low Man’s Lyric (7:37)
lyrics: lyrics not found: Metallica - Reload - 12. Attitude (5:16)
lyrics: lyrics not found: Metallica - Reload - 13. Fixxxer (8:14)
Sending event: cli_exit


Led to this problem:

```
Modified config to use lrclib with synced lyrics
```

### Setup

* OS:  debian trixie
* Python version: 3.11.8
* beets version: 1.6.0
* Turning off plugins made problem go away (yes/no):  No

My configuration (output of `beet config`) is:

```yaml

lyrics:
    bing_lang_from: []
    auto: yes
    sources: lrclib
    synced: yes
    bing_client_secret: REDACTED
    bing_lang_to:
    google_API_key: REDACTED
    google_engine_ID: REDACTED
    genius_api_key: REDACTED
    fallback:
    force: no
    local: no

plugins:
- chroma
- fromfilename
- acousticbrainz
- embedart
- fetchart
- lastgenre
- lyrics
- mbsync
- metasync
- replaygain
- bucket
- inline
- the
- badfiles
- duplicates
- info
- mbcollection
- mbsubmit
- missing
- random
- types
- alternatives

asciify_paths: yes

format_item: $artist - $album - $track. $title ($length)
format_album: $albumartist - $album
sort_item: albumartist+ album+ disc+ track+
per_disc_numbering: yes
terminal_encoding: utf8

clutter:
- Thumbs.DB
- .DS_Store
- '*.m3u'
- .pls
- '*.jpg'
max_filename_length: 64
id3v23: yes

import:
    write: yes
    copy: yes
    move: no
    link: no
    hardlink: no
    resume: ask
    quiet: no
    log: ~/tmp/beetslog
    detail: yes

musicbrainz:
    user: gderber
    pass: REDACTED

match:
    strong_rec_thresh: 0.08
    max_rec:
        missing_tracks: low
        unmatched_tracks: medium
        track_length: strong
    preferred:
        countries:
        - US
        - XW
        - CA
        - XE
        - GB|UK
        - AU
        - DE
        media: [CD, Digital Media|File]

paths:
    singleton: '%ifdef{sort,$sort,Unsorted}/%ifdef{album_holiday,$album_holiday}%ifdef{soundtrack_type,$soundtrack_type}/%the{%asciify{$albumartist}}/Non-Album/%the{%asciify{$title}}'
    comp: '%ifdef{sort,$sort,Unsorted}/%ifdef{album_holiday,$album_holiday}%ifdef{soundtrack_type,$soundtrack_type}/%the{%asciify{$albumartist}}/%the{%asciify{$album}}/%if{$multidisc,%if{$lmultidisc,$disc,%right{$disc,1}}}$track - %asciify{$title}'
    default: '%ifdef{sort,$sort,Unsorted}/%ifdef{album_holiday,$album_holiday}%ifdef{soundtrack_type,$soundtrack_type}/%the{%asciify{$albumartist}}/%ifdef{series,%the{$series}}/%ifdef{series_no,$series_no - }%the{%asciify{$album}}/%if{$multidisc,%if{$lmultidisc,$disc,%right{$disc,1}}}$track - %asciify{$title}'
chroma:
    auto: yes
acousticbrainz:
    auto: yes
    force: no
    tags: []
edit:
    itemfields: track title artist album
    albumfields: album albumartist
embedart:
    auto: yes
    compare_threshold: 0
    ifempty: yes
    maxwidth: 0
    remove_art_file: no
    quality: 0

fetchart:
    auto: yes
    cautious: yes
    cover_names: cover front art album folder
    minwidth: 480
    maxwidth: 0
    quality: 0
    max_filesize: 0
    enforce_ratio: no
    sources:
    - filesystem
    - coverart
    - itunes
    - amazon
    - albumart
    google_key: REDACTED
    google_engine: 001442825323518660753:hrh5ch1gjzm
    fanarttv_key: REDACTED
    lastfm_key: REDACTED
    store_source: no
    high_resolution: no
    deinterlace: no
    cover_format:
ftintitle:
    auto: no
    format: feat. {0}
keyfinder:
    auto: no
lastgenre:
    auto: yes
    fallback: ''
    source: album
    whitelist: ~/.config/beets/genres.txt
    min_weight: 10
    count: 1
    canonical: no
    force: yes
    separator: ', '
    prefer_specific: no
    title_case: yes
metasync:
    source: amarok
replaygain:
    backend: gstreamer
    overwrite: yes
    targetlevel: 89
    auto: yes
    threads: 8
    parallel_on_import: no
    per_disc: no
    peak: 'true'
    r128: [Opus]
    r128_targetlevel: 84
bucket:
    bucket_year: [1980-89]
    extrapolate: yes
    bucket_alpha: []
    bucket_alpha_regex: {}
item_fields:
    multidisc: 1 if disctotal > 1 else 0
    lmultidisc: 1 if disctotal > 9 else 0

the:
    a: yes
    the: yes
    patterns: []
    strip: no
    format: '{0}, {1}'
thumbnails:
    force: yes
badfiles:
    commands:
        ogg: myoggchecker --opt1 --opt2
        flac: flac --test --warnings-as-errors --silent
duplicates:
    album: no
    checksum: ''
    copy: ''
    count: no
    delete: no
    format: ''
    full: no
    keys: []
    merge: no
    move: ''
    path: no
    tiebreak: {}
    strict: no
    tag: ''
mbcollection:
    auto: no
    collection: ''
    remove: no
mbsubmit:
    threshold: medium
    format: $track. $title - $artist ($length)
missing:
    format: $albumartist - $album - $track - $title
    count: no
    total: no
    album: no
types:
    rating: int
    playcount: int
```
Not really sure if that helps or not.

https://lrclib.net/search/Metallica%20Hero%20of%20the%20Day for example exists.
I also have this issue.

```
beets version 1.6.0
Python version 3.11.2
plugins: convert, embedart, fetchart, ftintitle, lastgenre, lyrics, mpdupdate, random, replaygain, smartplaylist
```
Same here, I get a `lyrics: lyrics not found:` error for every track when I use the `lrclib` source.

I can't use other sources because Navidrome only supports synced lyrics, and `lrclib` is the only source where I can get them from.

My lyrics config:
```
lyrics:
    bing_lang_from: []
    force: yes
    sources: lrclib
    synced: yes
    auto: yes
    bing_client_secret: REDACTED
    bing_lang_to:
    google_API_key: REDACTED
    google_engine_ID: REDACTED
    genius_api_key: REDACTED
    fallback:
    local: no
```

Maybe LRCLIB had some [API changes](https://lrclib.net/docs) under the hood? 
> Although we intend to maintain backward compatibility, please be aware that there may be breaking changes in future updates.
I have fixed this issue locally, will submit a PR in a couple of days!



Patch:
diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py
index c00194f091..d1d715ce46 100644
--- a/beetsplug/lyrics.py
+++ b/beetsplug/lyrics.py
@@ -25,11 +25,15 @@
 import struct
 import unicodedata
 import warnings
-from functools import partial
-from typing import TYPE_CHECKING, ClassVar
+from contextlib import suppress
+from dataclasses import dataclass
+from functools import cached_property, partial, total_ordering
+from http import HTTPStatus
+from typing import TYPE_CHECKING, ClassVar, Iterable, Iterator
 from urllib.parse import quote, urlencode
 
 import requests
+from typing_extensions import TypedDict
 from unidecode import unidecode
 
 import beets
@@ -59,6 +63,7 @@
 TAG_RE = re.compile(r"<[^>]*>")
 BREAK_RE = re.compile(r"\n?\s*<br([\s|/][^>]*)*>\s*\n?", re.I)
 USER_AGENT = f"beets/{beets.__version__}"
+INSTRUMENTAL_LYRICS = "[Instrumental]"
 
 # The content for the base index.rst generated in ReST mode.
 REST_INDEX_TEMPLATE = """Lyrics
@@ -96,6 +101,10 @@
 """
 
 
+class NotFoundError(requests.exceptions.HTTPError):
+    pass
+
+
 # Utilities.
 
 
@@ -266,37 +275,154 @@ def fetch(
         raise NotImplementedError
 
 
+class LRCLibItem(TypedDict):
+    """Lyrics data item returned by the LRCLib API."""
+
+    id: int
+    name: str
+    trackName: str
+    artistName: str
+    albumName: str
+    duration: float | None
+    instrumental: bool
+    plainLyrics: str
+    syncedLyrics: str | None
+
+
+@dataclass
+@total_ordering
+class LRCLyrics:
+    #: Percentage tolerance for max duration difference between lyrics and item.
+    DURATION_DIFF_TOLERANCE = 0.05
+
+    target_duration: float
+    duration: float
+    instrumental: bool
+    plain: str
+    synced: str | None
+
+    def __le__(self, other: LRCLyrics) -> bool:
+        """Compare two lyrics items by their score."""
+        return self.dist < other.dist
+
+    @classmethod
+    def make(cls, candidate: LRCLibItem, target_duration: float) -> LRCLyrics:
+        return cls(
+            target_duration,
+            candidate["duration"] or 0.0,
+            candidate["instrumental"],
+            candidate["plainLyrics"],
+            candidate["syncedLyrics"],
+        )
+
+    @cached_property
+    def duration_dist(self) -> float:
+        """Return the absolute difference between lyrics and target duration."""
+        return abs(self.duration - self.target_duration)
+
+    @cached_property
+    def is_valid(self) -> bool:
+        """Return whether the lyrics item is valid.
+        Lyrics duration must be within the tolerance defined by
+        :attr:`DURATION_DIFF_TOLERANCE`.
+        """
+        return (
+            self.duration_dist
+            <= self.target_duration * self.DURATION_DIFF_TOLERANCE
+        )
+
+    @cached_property
+    def dist(self) -> tuple[bool, float]:
+        """Distance/score of the given lyrics item.
+
+        Return a tuple with the following values:
+        1. Absolute difference between lyrics and target duration
+        2. Boolean telling whether synced lyrics are available.
+
+        Best lyrics match is the one that has the closest duration to
+        ``target_duration`` and has synced lyrics available.
+        """
+        return not self.synced, self.duration_dist
+
+    def get_text(self, want_synced: bool) -> str:
+        if self.instrumental:
+            return INSTRUMENTAL_LYRICS
+
+        if want_synced and self.synced:
+            return "\n".join(map(str.strip, self.synced.splitlines()))
+
+        return self.plain
+
+
 class LRCLib(Backend):
-    base_url = "https://lrclib.net/api/get"
+    """Fetch lyrics from the LRCLib API."""
 
-    def fetch(
+    BASE_URL = "https://lrclib.net/api"
+    GET_URL = f"{BASE_URL}/get"
+    SEARCH_URL = f"{BASE_URL}/search"
+
+    def warn(self, message: str, *args) -> None:
+        """Log a warning message with the class name."""
+        self._log.warning(f"{self.__class__.__name__}: {message}", *args)
+
+    def fetch_json(self, *args, **kwargs):
+        """Wrap the request method to raise an exception on HTTP errors."""
+        kwargs.setdefault("timeout", 10)
+        kwargs.setdefault("headers", {"User-Agent": USER_AGENT})
+        r = requests.get(*args, **kwargs)
+        if r.status_code == HTTPStatus.NOT_FOUND:
+            raise NotFoundError("HTTP Error: Not Found", response=r)
+        r.raise_for_status()
+
+        return r.json()
+
+    def fetch_candidates(
         self, artist: str, title: str, album: str, length: int
-    ) -> str | None:
-        params: dict[str, str | int] = {
-            "artist_name": artist,
-            "track_name": title,
-        }
+    ) -> Iterator[list[LRCLibItem]]:
+        """Yield lyrics candidates for the given song data.
+
+        I found that the ``/get`` endpoint sometimes returns inaccurate or
+        unsynced lyrics, while ``search`` yields more suitable candidates.
+        Therefore, we prioritize the latter and rank the results using our own
+        algorithm. If the search does not give suitable lyrics, we fall back to
+        the ``/get`` endpoint.
+
+        Return an iterator over lists of candidates.
+        """
+        base_params = {"artist_name": artist, "track_name": title}
+        get_params = {**base_params, "duration": length}
         if album:
-            params["album_name"] = album
+            get_params["album_name"] = album
 
-        if length:
-            params["duration"] = length
+        yield self.fetch_json(self.SEARCH_URL, params=base_params)
 
-        try:
-            response = requests.get(
-                self.base_url,
-                params=params,
-                timeout=10,
-            )
-            data = response.json()
-        except (requests.RequestException, json.decoder.JSONDecodeError) as exc:
-            self._log.debug("LRCLib API request failed: {0}", exc)
-            return None
+        with suppress(NotFoundError):
+            yield [self.fetch_json(self.GET_URL, params=get_params)]
+
+    @classmethod
+    def pick_best_match(cls, lyrics: Iterable[LRCLyrics]) -> LRCLyrics | None:
+        """Return best matching lyrics item from the given list."""
+        return min((li for li in lyrics if li.is_valid), default=None)
+
+    def fetch(
+        self, artist: str, title: str, album: str, length: int
+    ) -> str | None:
+        """Fetch lyrics text for the given song data."""
+        evaluate_item = partial(LRCLyrics.make, target_duration=length)
 
-        if self.config["synced"]:
-            return data.get("syncedLyrics") or data.get("plainLyrics")
+        try:
+            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):
+                    return item.get_text(self.config["synced"])
+        except StopIteration:
+            pass
+        except requests.JSONDecodeError:
+            self.warn("Could not decode response JSON data")
+        except requests.RequestException as exc:
+            self.warn("Request error: {}", exc)
 
-        return data.get("plainLyrics")
+        return None
 
 
 class DirectBackend(Backend):
@@ -478,7 +604,7 @@ def _try_extracting_lyrics_from_non_data_lyrics_container(self, soup):
                 string="This song is an instrumental",
             ):
                 self._log.debug("Detected instrumental")
-                return "[Instrumental]"
+                return INSTRUMENTAL_LYRICS
             else:
                 self._log.debug("Couldn't scrape page using known layouts")
                 return None
@@ -725,7 +851,7 @@ def fetch(self, artist: str, title: str, *_) -> str | None:
 
 
 class LyricsPlugin(plugins.BeetsPlugin):
-    SOURCES = ["google", "musixmatch", "genius", "tekstowo", "lrclib"]
+    SOURCES = ["lrclib", "google", "musixmatch", "genius", "tekstowo"]
     SOURCE_BACKENDS = {
         "google": Google,
         "musixmatch": MusiXmatch,
diff --git a/docs/changelog.rst b/docs/changelog.rst
index fef29f5461..6ce13a8144 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -48,6 +48,13 @@ Bug fixes:
 * :doc:`plugins/lyrics`: Do not attempt to search for lyrics if either the
   artist or title is missing and ignore ``artist_sort`` value if it is empty.
   :bug:`2635`
+* :doc:`plugins/lyrics`: Fix fetching lyrics from ``lrclib`` source. If we
+  cannot find lyrics for a specific album, artist, title combination, the
+  plugin now tries to search for the artist and title and picks the most
+  relevant result. Update the default ``sources`` configuration to prioritize
+  ``lrclib`` over other sources since it returns reliable results quicker than
+  others.
+  :bug:`5102`
 
 For packagers:
 
diff --git a/docs/plugins/lyrics.rst b/docs/plugins/lyrics.rst
index 4939476938..d1f434d70f 100644
--- a/docs/plugins/lyrics.rst
+++ b/docs/plugins/lyrics.rst
@@ -56,7 +56,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 lrclib``, i.e., all the available sources. The
+  Default: ``lrclib google genius tekstowo``, 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


Test patch:
diff --git a/test/plugins/test_lyrics.py b/test/plugins/test_lyrics.py
index 99e6f8a4e8..47c9837701 100644
--- a/test/plugins/test_lyrics.py
+++ b/test/plugins/test_lyrics.py
@@ -14,7 +14,9 @@
 
 """Tests for the 'lyrics' plugin."""
 
+import re
 from functools import partial
+from http import HTTPStatus
 
 import pytest
 
@@ -228,7 +230,7 @@ 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_page.artist, lyrics_page.track_title, "", 0
+            lyrics_page.artist, lyrics_page.track_title, "", 186
         )
 
         assert lyrics
@@ -340,32 +342,41 @@ def test_scrape(self, backend, lyrics_html, expecting_lyrics):
         assert bool(backend.extract_lyrics(lyrics_html)) == expecting_lyrics
 
 
+LYRICS_DURATION = 950
+
+
+def lyrics_match(**overrides):
+    return {
+        "instrumental": False,
+        "duration": LYRICS_DURATION,
+        "syncedLyrics": "synced",
+        "plainLyrics": "plain",
+        **overrides,
+    }
+
+
 class TestLRCLibLyrics(LyricsBackendTest):
+    ITEM_DURATION = 999
+
     @pytest.fixture(scope="class")
     def backend_name(self):
         return "lrclib"
 
     @pytest.fixture
-    def fetch_lyrics(self, backend, requests_mock, response_data):
-        requests_mock.get(lyrics.LRCLib.base_url, json=response_data)
+    def request_kwargs(self, response_data):
+        return {"json": response_data}
 
-        return partial(backend.fetch, "la", "la", "la", 0)
+    @pytest.fixture
+    def fetch_lyrics(self, backend, requests_mock, request_kwargs):
+        requests_mock.get(backend.GET_URL, status_code=HTTPStatus.NOT_FOUND)
+        requests_mock.get(backend.SEARCH_URL, **request_kwargs)
 
-    @pytest.mark.parametrize(
-        "response_data",
-        [
-            {
-                "syncedLyrics": "[00:00.00] la la la",
-                "plainLyrics": "la la la",
-            }
-        ],
-    )
+        return partial(backend.fetch, "la", "la", "la", self.ITEM_DURATION)
+
+    @pytest.mark.parametrize("response_data", [[lyrics_match()]])
     @pytest.mark.parametrize(
         "plugin_config, expected_lyrics",
-        [
-            ({"synced": True}, "[00:00.00] la la la"),
-            ({"synced": False}, "la la la"),
-        ],
+        [({"synced": True}, "synced"), ({"synced": False}, "plain")],
     )
     def test_synced_config_option(self, fetch_lyrics, expected_lyrics):
         assert fetch_lyrics() == expected_lyrics
@@ -373,21 +384,77 @@ def test_synced_config_option(self, fetch_lyrics, expected_lyrics):
     @pytest.mark.parametrize(
         "response_data, expected_lyrics",
         [
+            pytest.param([], None, id="handle non-matching lyrics"),
             pytest.param(
-                {"syncedLyrics": "", "plainLyrics": "la la la"},
-                "la la la",
-                id="pick plain lyrics",
+                [lyrics_match()],
+                "synced",
+                id="synced when available",
             ),
             pytest.param(
-                {
-                    "statusCode": 404,
-                    "error": "Not Found",
-                    "message": "Failed to find specified track",
-                },
+                [lyrics_match(duration=1)],
                 None,
-                id="not found",
+                id="none: duration too short",
+            ),
+            pytest.param(
+                [lyrics_match(instrumental=True)],
+                "[Instrumental]",
+                id="instrumental track",
+            ),
+            pytest.param(
+                [lyrics_match(syncedLyrics=None)],
+                "plain",
+                id="plain by default",
+            ),
+            pytest.param(
+                [
+                    lyrics_match(
+                        duration=ITEM_DURATION,
+                        syncedLyrics=None,
+                        plainLyrics="plain with closer duration",
+                    ),
+                    lyrics_match(syncedLyrics="synced", plainLyrics="plain 2"),
+                ],
+                "synced",
+                id="prefer synced lyrics even if plain duration is closer",
+            ),
+            pytest.param(
+                [
+                    lyrics_match(
+                        duration=ITEM_DURATION,
+                        syncedLyrics=None,
+                        plainLyrics="valid plain",
+                    ),
+                    lyrics_match(
+                        duration=1,
+                        syncedLyrics="synced with invalid duration",
+                    ),
+                ],
+                "valid plain",
+                id="ignore synced with invalid duration",
+            ),
+            pytest.param(
+                [lyrics_match(syncedLyrics=None), lyrics_match()],
+                "synced",
+                id="prefer match with synced lyrics",
             ),
         ],
     )
+    @pytest.mark.parametrize("plugin_config", [{"synced": True}])
     def test_fetch_lyrics(self, fetch_lyrics, expected_lyrics):
         assert fetch_lyrics() == expected_lyrics
+
+    @pytest.mark.parametrize(
+        "request_kwargs, expected_log_match",
+        [
+            (
+                {"status_code": HTTPStatus.BAD_GATEWAY},
+                r"LRCLib: Request error: 502",
+            ),
+            ({"text": "invalid"}, r"LRCLib: Could not decode.*JSON"),
+        ],
+    )
+    def test_error(self, caplog, fetch_lyrics, expected_log_match):
+        assert fetch_lyrics() is None
+        assert caplog.messages
+        assert (last_log := caplog.messages[-1])
+        assert re.search(expected_log_match, last_log, re.I)