/
/
/
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, 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
42class GeniusProvider(MetadataProvider):
43 """Genius Lyrics provider for handling lyrics."""
44
45 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
46 """Return Config entries to setup this provider."""
47 return () # we do not have any config entries (yet)
48
49 async def handle_async_init(self) -> None:
50 """Handle async initialization of the provider."""
51 self._genius = Genius("public", skip_non_songs=True, remove_section_headers=True)
52
53 async def get_track_metadata(self, track: Track) -> MediaItemMetadata | None:
54 """Retrieve synchronized lyrics for a track."""
55 if track.metadata and (track.metadata.lyrics or track.metadata.lrc_lyrics):
56 self.logger.debug("Skipping lyrics lookup for %s: Already has lyrics", track.name)
57 return None
58
59 if not track.artists:
60 self.logger.info("Skipping lyrics lookup for %s: No artist information", track.name)
61 return None
62
63 artist_name = track.artists[0].name
64
65 if not track.name or len(track.name.strip()) == 0:
66 self.logger.info(
67 "Skipping lyrics lookup for %s: No track name information", artist_name
68 )
69 return None
70
71 song_lyrics = await self.fetch_lyrics(artist_name, track.name)
72
73 if song_lyrics:
74 metadata = MediaItemMetadata()
75 metadata.lyrics = song_lyrics
76
77 self.logger.debug("Found lyrics for %s by %s", track.name, artist_name)
78 return metadata
79
80 self.logger.info("No lyrics found for %s by %s", track.name, artist_name)
81 return None
82
83 @use_cache(86400 * 7) # Cache for 7 days
84 async def fetch_lyrics(self, artist: str, title: str) -> str | None:
85 """Fetch lyrics for a given artist and title."""
86
87 def _fetch_lyrics(artist: str, title: str) -> str | None:
88 """Fetch lyrics - NOTE: not async friendly."""
89 # blank artist / title?
90 if (
91 artist is None
92 or len(artist.strip()) == 0
93 or title is None
94 or len(title.strip()) == 0
95 ):
96 self.logger.error("Cannot fetch lyrics without artist and title")
97 return None
98
99 # clean song title to increase chance and accuracy of a result
100 cleaned_title = clean_song_title(title)
101 if cleaned_title != title:
102 self.logger.debug(f'Song title was cleaned: "{title}" -> "{cleaned_title}"')
103
104 self.logger.info(f"Searching lyrics for artist='{artist}' and title='{cleaned_title}'")
105
106 # perform search
107 song = self._genius.search_song(cleaned_title, artist, get_full_info=False)
108
109 # second search needed?
110 if not song and " - " in cleaned_title:
111 # aggressively truncate title from the first hyphen
112 cleaned_title = cleaned_title.split(" - ", 1)[0]
113 self.logger.info(f"Second attempt, aggressively cleaned title='{cleaned_title}'")
114
115 # perform search
116 song = self._genius.search_song(cleaned_title, artist, get_full_info=False)
117
118 if song:
119 # attempts to clean lyrics of erroneous text
120 return cleanup_lyrics(song)
121
122 return None
123
124 return await asyncio.to_thread(_fetch_lyrics, artist, title)
125