music-assistant-server

4.9 KBPY
__init__.py
4.9 KB138 lines • python
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