From af4eb6f972d7de18521e62e0b45773f7a6c13eae Mon Sep 17 00:00:00 2001 From: Robert Alfaro Date: Tue, 9 Sep 2025 03:23:08 -0700 Subject: [PATCH] Adding support for Genius Lyrics metadata provider (#2337) --- .../providers/genius_lyrics/__init__.py | 129 +++ .../providers/genius_lyrics/helpers.py | 49 + .../providers/genius_lyrics/icon.svg | 947 ++++++++++++++++++ .../providers/genius_lyrics/manifest.json | 12 + requirements_all.txt | 1 + 5 files changed, 1138 insertions(+) create mode 100644 music_assistant/providers/genius_lyrics/__init__.py create mode 100644 music_assistant/providers/genius_lyrics/helpers.py create mode 100644 music_assistant/providers/genius_lyrics/icon.svg create mode 100644 music_assistant/providers/genius_lyrics/manifest.json diff --git a/music_assistant/providers/genius_lyrics/__init__.py b/music_assistant/providers/genius_lyrics/__init__.py new file mode 100644 index 00000000..853567c9 --- /dev/null +++ b/music_assistant/providers/genius_lyrics/__init__.py @@ -0,0 +1,129 @@ +""" +The Genius Lyrics Metadata provider for Music Assistant. + +Used for retrieval of lyrics. +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +from music_assistant_models.enums import ProviderFeature +from music_assistant_models.media_items import MediaItemMetadata, Track + +from music_assistant.models.metadata_provider import MetadataProvider + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig + from music_assistant_models.provider import ProviderManifest + + from music_assistant.mass import MusicAssistant + from music_assistant.models import ProviderInstanceType + +from lyricsgenius import Genius + +from .helpers import clean_song_title, cleanup_lyrics + +SUPPORTED_FEATURES = { + ProviderFeature.TRACK_METADATA, +} + + +async def setup( + mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig +) -> ProviderInstanceType: + """Initialize provider(instance) with given configuration.""" + return GeniusProvider(mass, manifest, config) + + +async def get_config_entries( + mass: MusicAssistant, + instance_id: str | None = None, + action: str | None = None, + values: dict[str, ConfigValueType] | None = None, +) -> tuple[ConfigEntry, ...]: + """ + Return Config entries to setup this provider. + + instance_id: id of an existing provider instance (None if new instance setup). + action: [optional] action key called from config entries UI. + values: the (intermediate) raw values for config entries sent with the action. + """ + # ruff: noqa: ARG001 + return () # we do not have any config entries (yet) + + +class GeniusProvider(MetadataProvider): + """Genius Lyrics provider for handling lyrics.""" + + async def handle_async_init(self) -> None: + """Handle async initialization of the provider.""" + self._genius = Genius("public", skip_non_songs=True, remove_section_headers=True) + + @property + def supported_features(self) -> set[ProviderFeature]: + """Return the features supported by this Provider.""" + return SUPPORTED_FEATURES + + async def get_track_metadata(self, track: Track) -> MediaItemMetadata | None: + """Retrieve synchronized lyrics for a track.""" + if track.metadata and (track.metadata.lyrics or track.metadata.lrc_lyrics): + self.logger.debug("Skipping lyrics lookup for %s: Already has lyrics", track.name) + return None + + if not track.artists: + self.logger.debug("Skipping lyrics lookup for %s: No artist information", track.name) + return None + + artist_name = track.artists[0].name + + if not track.name or len(track.name.strip()) == 0: + self.logger.debug( + "Skipping lyrics lookup for %s: No track name information", artist_name + ) + return None + + song_lyrics = await asyncio.to_thread(self._fetch_lyrics, artist_name, track.name) + + if song_lyrics: + metadata = MediaItemMetadata() + metadata.lyrics = song_lyrics + + self.logger.debug("Found lyrics for %s by %s", track.name, artist_name) + return metadata + + self.logger.debug("No lyrics found for %s by %s", track.name, artist_name) + return None + + def _fetch_lyrics(self, artist: str, title: str) -> str | None: + """Fetch lyrics - NOTE: not async friendly.""" + # blank artist / title? + if artist is None or len(artist.strip()) == 0 or title is None or len(title.strip()) == 0: + self.logger.error("Cannot fetch lyrics without artist and title") + return None + + # clean song title to increase chance and accuracy of a result + cleaned_title = clean_song_title(title) + if cleaned_title != title: + self.logger.debug(f'Song title was cleaned: "{title}" -> "{cleaned_title}"') + + self.logger.info(f"Searching lyrics for artist='{artist}' and title='{cleaned_title}'") + + # perform search + song = self._genius.search_song(cleaned_title, artist, get_full_info=False) + + # second search needed? + if not song and " - " in cleaned_title: + # aggressively truncate title from the first hyphen + cleaned_title = cleaned_title.split(" - ", 1)[0] + self.logger.info(f"Second attempt, aggressively cleaned title='{cleaned_title}'") + + # perform search + song = self._genius.search_song(cleaned_title, artist, get_full_info=False) + + if song: + # attempts to clean lyrics of erroneous text + return cleanup_lyrics(song) + + return None diff --git a/music_assistant/providers/genius_lyrics/helpers.py b/music_assistant/providers/genius_lyrics/helpers.py new file mode 100644 index 00000000..74b81061 --- /dev/null +++ b/music_assistant/providers/genius_lyrics/helpers.py @@ -0,0 +1,49 @@ +"""Helpers for the Genius Lyrics provider.""" + +import re + +from lyricsgenius.types import Song + + +def clean_song_title(song_title: str) -> str: + """Clean song title string by removing metadata that may appear.""" + # Keywords to look for in parentheses, brackets, or after a hyphen + keywords = ( + r"(remaster(?:ed)?|anniversary|instrumental|live|edit(?:ion)?|" + r"single(s)?|stereo|album|radio|version|feat(?:uring)?|mix|bonus)" + ) + + # Regex pattern to match metadata within parentheses or brackets + paren_bracket_pattern = rf"[\(\[][^\)\]]*\b({keywords})\b[^\)\]]*[\)\]]" + cleaned_title = re.sub(paren_bracket_pattern, "", song_title, flags=re.IGNORECASE) + + # Regex pattern to match a hyphen followed by metadata (keywords or a year) + hyphen_pattern = rf"(\s*-\s*(\d{{4}}|{keywords}).*)$" + cleaned_title = re.sub(hyphen_pattern, "", cleaned_title, flags=re.IGNORECASE) + + # Remove any dangling hyphens or extra spaces + cleaned_title = re.sub(r"\s*-\s*$", "", cleaned_title).strip() + + # Remove any leftover unmatched parentheses or brackets + return re.sub(r"\s[\(\[\{\]\)\}\s]+$", "", cleaned_title).strip() + + +def cleanup_lyrics(song: Song) -> str: + """Clean lyrics string hackishly remove erroneous text that may appear.""" + # Pattern1: match digits at beginning followed by "Contributors" and text followed by "Lyrics" + pattern1 = r"^(\d+) Contributor(.*?) Lyrics" + lyrics = re.sub(pattern1, "", song.lyrics, flags=re.DOTALL) + + # Pattern2: match ending with "Embed" + lyrics = lyrics.rstrip("Embed") + + # Pattern3: match ending with Pyong Count + lyrics = lyrics.rstrip(str(song.pyongs_count)) + + # Pattern4: match "See [artist] LiveGet tickets as low as $[price]" + pattern4 = rf"See {song.artist} LiveGet tickets as low as \$\d+" + lyrics = re.sub(pattern4, "", lyrics) + + # Pattern5: match "You might also like" not followed by whitespace + pattern5 = r"You might also like(?!\s)" + return re.sub(pattern5, "", lyrics) diff --git a/music_assistant/providers/genius_lyrics/icon.svg b/music_assistant/providers/genius_lyrics/icon.svg new file mode 100644 index 00000000..36384156 --- /dev/null +++ b/music_assistant/providers/genius_lyrics/icon.svg @@ -0,0 +1,947 @@ + + + + + + + + + + diff --git a/music_assistant/providers/genius_lyrics/manifest.json b/music_assistant/providers/genius_lyrics/manifest.json new file mode 100644 index 00000000..25a87aba --- /dev/null +++ b/music_assistant/providers/genius_lyrics/manifest.json @@ -0,0 +1,12 @@ +{ + "type": "metadata", + "domain": "genius_lyrics", + "stage": "alpha", + "name": "Genius Lyrics", + "description": "Genius.com is a free service for finding song lyrics and annotations.", + "codeowners": ["@robert-alfaro"], + "requirements": ["lyricsgenius==3.6.5"], + "documentation": "https://www.music-assistant.io/metadata/", + "multi_instance": false, + "icon": "mdi-script-text" +} diff --git a/requirements_all.txt b/requirements_all.txt index 7a48fe63..7fbd582e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -28,6 +28,7 @@ hass-client==1.2.0 ibroadcastaio==0.4.0 ifaddr==0.2.0 liblistenbrainz==0.6.0 +lyricsgenius==3.6.5 mashumaro==3.16 music-assistant-frontend==2.15.3 music-assistant-models==1.1.53 -- 2.34.1