Files
beets/beetsplug/_typing.py
T
David Howard cd50a42c04 lyrics: don't match LRCLib entries that have no lyrics
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.
2026-08-15 17:49:54 -07:00

146 lines
3.8 KiB
Python

from __future__ import annotations
from typing import Any
from typing_extensions import NotRequired, TypedDict
JSONDict = dict[str, Any]
class LRCLibAPI:
class Item(TypedDict):
"""Lyrics data item returned by the LRCLib API."""
id: int
name: str
trackName: str
artistName: str
albumName: str
duration: float | None
instrumental: bool
# Both lyrics fields may be null, even when 'instrumental' is False.
plainLyrics: str | None
syncedLyrics: str | None
class GeniusAPI:
"""Genius API data types.
This documents *only* the fields that are used in the plugin.
:attr:`SearchResult` is an exception, since I thought some of the other
fields might be useful in the future.
"""
class DateComponents(TypedDict):
year: int
month: int
day: int
class Artist(TypedDict):
api_path: str
header_image_url: str
id: int
image_url: str
is_meme_verified: bool
is_verified: bool
name: str
url: str
class Stats(TypedDict):
unreviewed_annotations: int
hot: bool
class SearchResult(TypedDict):
annotation_count: int
api_path: str
artist_names: str
full_title: str
header_image_thumbnail_url: str
header_image_url: str
id: int
lyrics_owner_id: int
lyrics_state: str
path: str
primary_artist_names: str
pyongs_count: int | None
relationships_index_url: str
release_date_components: GeniusAPI.DateComponents
release_date_for_display: str
release_date_with_abbreviated_month_for_display: str
song_art_image_thumbnail_url: str
song_art_image_url: str
stats: GeniusAPI.Stats
title: str
title_with_featured: str
url: str
featured_artists: list[GeniusAPI.Artist]
primary_artist: GeniusAPI.Artist
primary_artists: list[GeniusAPI.Artist]
class SearchHit(TypedDict):
result: GeniusAPI.SearchResult
class SearchResponse(TypedDict):
hits: list[GeniusAPI.SearchHit]
class Search(TypedDict):
response: GeniusAPI.SearchResponse
class StatusResponse(TypedDict):
status: int
message: str
class Meta(TypedDict):
meta: GeniusAPI.StatusResponse
Response = Search | Meta
class GoogleCustomSearchAPI:
class Response(TypedDict):
"""Search response from the Google Custom Search API.
If the search returns no results, the :attr:`items` field is not found.
"""
items: NotRequired[list[GoogleCustomSearchAPI.Item]]
class Item(TypedDict):
"""A Google Custom Search API result item.
:attr:`title` field is shown to the user in the search interface, thus
it gets truncated with an ellipsis for longer queries. For most
results, the full title is available as ``og:title`` metatag found
under the :attr:`pagemap` field. Note neither this metatag nor the
``pagemap`` field is guaranteed to be present in the data.
"""
title: str
link: str
pagemap: NotRequired[GoogleCustomSearchAPI.Pagemap]
class Pagemap(TypedDict):
"""Pagemap data with a single meta tags dict in a list."""
metatags: list[JSONDict]
class TranslatorAPI:
class Language(TypedDict):
"""Language data returned by the translator API."""
language: str
score: float
class Translation(TypedDict):
"""Translation data returned by the translator API."""
text: str
to: str
class Response(TypedDict):
"""Response from the translator API."""
detectedLanguage: TranslatorAPI.Language
translations: list[TranslatorAPI.Translation]