/
/
/
1"""
2The Genius Lyrics Metadata provider for Music Assistant.
3
4Used for retrieval of lyrics.
5"""
6
7from __future__ import annotations
8
9import asyncio
10from typing import TYPE_CHECKING
11
12from music_assistant_models.enums import ProviderFeature
13from music_assistant_models.media_items import MediaItemMetadata, Track
14
15from music_assistant.controllers.cache import use_cache
16from music_assistant.models.metadata_provider import MetadataProvider
17
18if TYPE_CHECKING:
19 from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
20 from music_assistant_models.provider import ProviderManifest
21
22 from music_assistant.mass import MusicAssistant
23 from music_assistant.models import ProviderInstanceType
24
25from lyricsgenius import Genius
26
27from .helpers import clean_song_title, cleanup_lyrics
28
29SUPPORTED_FEATURES = {
30 ProviderFeature.TRACK_METADATA,
31 ProviderFeature.LYRICS,
32}
33
34
35async def setup(
36 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
37) -> ProviderInstanceType:
38 """Initialize provider(instance) with given configuration."""
39 return GeniusProvider(mass, manifest, config, SUPPORTED_FEATURES)
40
41
42async def get_config_entries(
43 mass: MusicAssistant,
44 instance_id: str | None = None,
45 action: str | None = None,
46 values: dict[str, ConfigValueType] | None = None,
47) -> tuple[ConfigEntry, ...]:
48 """
49 Return Config entries to setup this provider.
50
51 instance_id: id of an existing provider instance (None if new instance setup).
52 action: [optional] action key called from config entries UI.
53 values: the (intermediate) raw values for config entries sent with the action.
54 """
55 # ruff: noqa: ARG001
56 return () # we do not have any config entries (yet)
57
58
59class GeniusProvider(MetadataProvider):
60 """Genius Lyrics provider for handling lyrics."""
61
62 async def handle_async_init(self) -> None:
63 """Handle async initialization of the provider."""
64 self._genius = Genius("public", skip_non_songs=True, remove_section_headers=True)
65
66 async def get_track_metadata(self, track: Track) -> MediaItemMetadata | None:
67 """Retrieve synchronized lyrics for a track."""
68 if track.metadata and (track.metadata.lyrics or track.metadata.lrc_lyrics):
69 self.logger.debug("Skipping lyrics lookup for %s: Already has lyrics", track.name)
70 return None
71
72 if not track.artists:
73 self.logger.info("Skipping lyrics lookup for %s: No artist information", track.name)
74 return None
75
76 artist_name = track.artists[0].name
77
78 if not track.name or len(track.name.strip()) == 0:
79 self.logger.info(
80 "Skipping lyrics lookup for %s: No track name information", artist_name
81 )
82 return None
83
84 song_lyrics = await self.fetch_lyrics(artist_name, track.name)
85
86 if song_lyrics:
87 metadata = MediaItemMetadata()
88 metadata.lyrics = song_lyrics
89
90 self.logger.debug("Found lyrics for %s by %s", track.name, artist_name)
91 return metadata
92
93 self.logger.info("No lyrics found for %s by %s", track.name, artist_name)
94 return None
95
96 @use_cache(86400 * 7) # Cache for 7 days
97 async def fetch_lyrics(self, artist: str, title: str) -> str | None:
98 """Fetch lyrics for a given artist and title."""
99
100 def _fetch_lyrics(artist: str, title: str) -> str | None:
101 """Fetch lyrics - NOTE: not async friendly."""
102 # blank artist / title?
103 if (
104 artist is None
105 or len(artist.strip()) == 0
106 or title is None
107 or len(title.strip()) == 0
108 ):
109 self.logger.error("Cannot fetch lyrics without artist and title")
110 return None
111
112 # clean song title to increase chance and accuracy of a result
113 cleaned_title = clean_song_title(title)
114 if cleaned_title != title:
115 self.logger.debug(f'Song title was cleaned: "{title}" -> "{cleaned_title}"')
116
117 self.logger.info(f"Searching lyrics for artist='{artist}' and title='{cleaned_title}'")
118
119 # perform search
120 song = self._genius.search_song(cleaned_title, artist, get_full_info=False)
121
122 # second search needed?
123 if not song and " - " in cleaned_title:
124 # aggressively truncate title from the first hyphen
125 cleaned_title = cleaned_title.split(" - ", 1)[0]
126 self.logger.info(f"Second attempt, aggressively cleaned title='{cleaned_title}'")
127
128 # perform search
129 song = self._genius.search_song(cleaned_title, artist, get_full_info=False)
130
131 if song:
132 # attempts to clean lyrics of erroneous text
133 return cleanup_lyrics(song)
134
135 return None
136
137 return await asyncio.to_thread(_fetch_lyrics, artist, title)
138