Skip to content

lyrics: don't match LRCLib entries that have no lyrics - #6900

Open
davidbhoward wants to merge 3 commits into
beetbox:masterfrom
davidbhoward:fix/lrclib-null-lyrics
Open

lyrics: don't match LRCLib entries that have no lyrics#6900
davidbhoward wants to merge 3 commits into
beetbox:masterfrom
davidbhoward:fix/lrclib-null-lyrics

Conversation

@davidbhoward

Copy link
Copy Markdown

Description

LRCLib stores track metadata independently of the lyrics themselves, so an entry can come back with both plainLyrics and syncedLyrics null while instrumental is still False.

LRCLyrics.is_valid accepted such an entry as a match on duration alone, and get_text then returned self.plain, i.e. None, despite being annotated -> str. The None propagated into Lyrics, and the first access of its text raised:

AttributeError: 'NoneType' object has no attribute 'splitlines'
  File "beets/util/lyrics.py", line 108, in _split_lines
    for line in self.text.splitlines()

beet lyrics surfaces this per track, but during an import the exception escapes the pipeline stage and aborts the entire run, so one such track strands every file queued behind it. Instrumental-heavy material (lo-fi, game soundtracks, ambient) hits it often.

Reproducing

Real response from the public API, https://lrclib.net/api/search?track_name=Anther&artist_name=Blue%20Wednesday:

{
  "id": 37048543,
  "trackName": "Anther",
  "artistName": "Blue Wednesday",
  "duration": 189.0,
  "instrumental": false,
  "plainLyrics": null,
  "syncedLyrics": null
}

Against master, with no configuration involved:

candidate.is_valid  = True     <- accepted as a match
get_text() returned = None     <- annotated `-> str`
AttributeError: 'NoneType' object has no attribute 'splitlines'

Changes

  • An entry with no lyrics text at all is no longer a valid match, so the search continues to other candidates and backends and ultimately reports that no lyrics were found. This is deliberately kept distinct from an instrumental track, where "no lyrics" is itself the answer and the existing instrumental handling is unchanged. Marking these as instrumental would assert something the API response does not tell us: the lyrics may simply not have been contributed yet.
  • A null plainLyrics now falls back to synced lyrics rather than discarding lyrics that are present.
  • LRCLibAPI.Item.plainLyrics and LRCLyrics.plain are annotated as nullable, matching what the API actually returns.

Tests

Two cases added to TestLRCLibLyrics, both of which fail on master and pass with this change:

  • none: no lyrics text despite instrumental being False
  • test_null_plain_lyrics_falls_back_to_synced

Full test/plugins/test_lyrics.py suite passes (127 passed, 16 skipped), along with ruff check, ruff format --check, and mypy.

@davidbhoward
davidbhoward requested a review from a team as a code owner August 1, 2026 20:49
@github-actions github-actions Bot added the lyrics lyrics plugin label Aug 1, 2026
@davidbhoward

Copy link
Copy Markdown
Author

Full disclosure on where this came from: I have not personally reviewed the code in this
PR. Claude found the issue while backfilling lyrics across my music library and wrote the
fix and the tests. What I hit was imports aborting partway through on instrumental lo-fi
tracks, which left the rest of the batch sitting unimported without any obvious error.

The repro in the description is a real response from the LRCLib API, so at minimum the
underlying behavior is worth knowing about even if you want to solve it differently.

Posting it in case it helps. Take it or leave it, no hard feelings either way.

@davidbhoward

davidbhoward commented Aug 1, 2026

Copy link
Copy Markdown
Author

The two failures in the ubuntu 3.10 job look unrelated to this change. I reverted
beetsplug/lyrics.py and _typing.py to master locally and ran the same two tests, and
they fail identically without my change.

Both are live network tests in TestLyricsSources. The lyricsmania one is the Google
backend, which this PR does not touch at all. The lrclib one has matching lyrics text
and only differs on the record URL, 23863037 versus the expected 19648857, so it looks
like LRCLib is serving a different record ID for the same song now.

These only run when lyrics source changes, so I think this PR just happened to be what
made them run.

@semohr

semohr commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Is this still an issue on the master branch?

I think #6864 should have fixed this already.

@davidbhoward

Copy link
Copy Markdown
Author

Is this still an issue on the master branch?

I think #6864 should have fixed this already.

Thanks for pointing at #6864. I checked and I don't think it covers this one: #6864 is
already in master (77b9dff) and the crash still reproduces on it.

The difference is where the None comes from. #6864 guards Lyrics.from_item(), which handles
item.lyrics being None for a track with no stored lyrics. This crash never goes through
from_item. LRCLib.fetch() builds the Lyrics object directly:

lyrics = item.get_text(self.config["synced"].get(bool))   # returns None
return Lyrics(lyrics, self.__class__.name, ...)

get_text() falls through to return self.plain, and plainLyrics is null in the API response
even though instrumental is false, so None reaches the constructor rather than from_item.

The repro in the PR description is a real LRCLib response and it still fails on current
master. Happy to be wrong if I've misread something.

@semohr

semohr commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Thanks for the clarification! Makes sense to me.

Comment thread beetsplug/lyrics.py Outdated
@snejus

snejus commented Aug 5, 2026

Copy link
Copy Markdown
Member

@davidbhoward I fixed lrclib integration test as it failed. Please address my comment and we will merge it :)

@davidbhoward

Copy link
Copy Markdown
Author

OK will do. I'm on vacation but will do it when I get back next week. Thank you.

Comment thread beetsplug/lyrics.py
davidbhoward and others added 3 commits August 15, 2026 17:49
LRCLib stores track metadata independently of the lyrics themselves, so an
entry can come back with both `plainLyrics` and `syncedLyrics` null while
`instrumental` is False. `LRCLyrics.is_valid` accepted such an entry as a
match on duration alone, and `get_text` then returned `self.plain`, i.e.
None, despite being annotated `-> str`.

The None propagated into `Lyrics`, and the first access of its text raised

    AttributeError: 'NoneType' object has no attribute 'splitlines'

`beet lyrics` surfaced this per track, but during an import the exception
escaped the pipeline stage and aborted the entire run: one such track
stranded every file queued behind it.

Treat an entry with no lyrics text as not a match, so the search moves on to
other candidates and backends and ultimately reports that no lyrics were
found. That is deliberately distinct from an instrumental track, where "no
lyrics" is itself the answer and the existing `instrumental` handling still
applies. Marking these as instrumental would assert something the API
response does not tell us.

Also fall back to synced lyrics when only `plainLyrics` is null, rather than
discarding lyrics we do have, and correct the annotations: `plainLyrics` and
`LRCLyrics.plain` are both nullable.
Return an empty string rather than INSTRUMENTAL_LYRICS from the final
fallback in LRCLyrics.get_text. That branch is not about an instrumental
track, so the marker was misleading.

Strip the LRC timestamps when synced lyrics stand in for a null
plainLyrics. The value is being used as plain text there, so the
timestamps do not belong in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@davidbhoward
davidbhoward force-pushed the fix/lrclib-null-lyrics branch from ff223fa to b19f0c0 Compare August 16, 2026 00:53
@davidbhoward

davidbhoward commented Aug 16, 2026

Copy link
Copy Markdown
Author

Both review points are addressed in f68aefa.

I also rebased onto current master rather than merging, so the earlier commit SHAs changed.
Your lrclib fixture fix is still there as b19f0c0 with your authorship, I cherry-picked it onto
the rebase so it did not get lost. Both TestLyricsSources live tests pass locally now.

I wasn't sure if this project prefers rebase or merging master into branch. Let me know if you need to take any action here. Sorry about that.

@davidbhoward

Copy link
Copy Markdown
Author

CI is red on ubuntu 3.10 again but it looks like more provider drift rather than anything
from this change.

The failure is now TestLyricsSources[lrcmux-lrcmux]:

FAILED test/plugins/test_lyrics.py::TestLyricsSources::test_backend_source[lrcmux-lrcmux]
AssertionError: assert '[00:00.00]\n...ake ends meet' == '[00:08.71] L...ake ends meet'

The only difference is a leading empty timestamp line that ytmusic has started returning. Every
other line matches exactly:

+ [00:00.00]
  [00:08.71] Lady Madonna, children at your feet
  [00:13.08] Wonder how you manage to make ends meet
  [00:17.43] Who finds the money when you pay the rent?
  ...

Reasons I do not think it is this PR:

I reverted beetsplug/lyrics.py and beetsplug/_typing.py to origin/master locally and the test
fails identically, so it reproduces without any of my changes.

This PR does not touch that fixture. The only change to lyrics_pages.py on this branch is your
lrclib URL fix.

The change itself cannot add a line. _synced_as_plain only removes timestamps and
_format_synced only trims whitespace on existing lines.

For what it is worth this is the third live source to drift on this PR now, first lyricsmania,
then the lrclib record ID you fixed, now ytmusic. Since these only run when lyrics source files
change, the drift seems to pile up and then land on whoever next touches the plugin.

Happy to add the leading [00:00.00] to the expected text in this PR if you want it fixed here,
or leave it for a separate one. Let me know which you prefer.

@davidbhoward
davidbhoward requested a review from snejus August 16, 2026 02:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lyrics lyrics plugin

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants