From: Marvin Schenkel Date: Tue, 15 Apr 2025 16:45:04 +0000 (+0200) Subject: YTMusic: Add recommendations (#2128) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=26730f6ad05505ca29402c3fbd314a9a9a57bd11;p=music-assistant-server.git YTMusic: Add recommendations (#2128) --- diff --git a/music_assistant/providers/ytmusic/__init__.py b/music_assistant/providers/ytmusic/__init__.py index 9f9b1686..89f0e1e2 100644 --- a/music_assistant/providers/ytmusic/__init__.py +++ b/music_assistant/providers/ytmusic/__init__.py @@ -41,6 +41,7 @@ from music_assistant_models.media_items import ( Podcast, PodcastEpisode, ProviderMapping, + RecommendationFolder, SearchResults, Track, ) @@ -50,13 +51,16 @@ from ytmusicapi.exceptions import YTMusicServerError from ytmusicapi.helpers import get_authorization, sapisid_from_cookie from music_assistant.constants import CONF_USERNAME, VERBOSE_LOG_LEVEL +from music_assistant.controllers.cache import use_cache from music_assistant.models.music_provider import MusicProvider from .helpers import ( add_remove_playlist_tracks, convert_to_netscape, + determine_recommendation_icon, get_album, get_artist, + get_home, get_library_albums, get_library_artists, get_library_playlists, @@ -123,6 +127,7 @@ SUPPORTED_FEATURES = { ProviderFeature.ARTIST_TOPTRACKS, ProviderFeature.SIMILAR_TRACKS, ProviderFeature.LIBRARY_PODCASTS, + ProviderFeature.RECOMMENDATIONS, } @@ -588,6 +593,45 @@ class YoutubeMusicProvider(MusicProvider): stream_details.audio_format.sample_rate = int(stream_format.get("asr")) return stream_details + @use_cache(3600) + async def recommendations(self) -> list[RecommendationFolder]: + """Get available recommendations.""" + recommendations = await get_home(self._headers, self.language, user=self._yt_user) + folders = [] + for section in recommendations: + folder = RecommendationFolder( + name=section["title"], + item_id=f"{self.instance_id}_{section['title']}", + provider=self.lookup_key, + icon=determine_recommendation_icon(section["title"]), + ) + for recommended_item in section.get("contents", []): + if recommended_item.get("videoId"): + # Probably a track + try: + track = self._parse_track(recommended_item) + folder.items.append(track) + except InvalidDataError: + self.logger.debug("Invalid track in recommendations: %s", recommended_item) + elif recommended_item.get("playlistId"): + # Probably a playlist + recommended_item["id"] = recommended_item["playlistId"] + del recommended_item["playlistId"] + folder.items.append(self._parse_playlist(recommended_item)) + elif recommended_item.get("browseId"): + # Probably an album + folder.items.append(self._parse_album(recommended_item)) + elif recommended_item.get("subscribers"): + # Probably artist + folder.items.append(self._parse_album(recommended_item)) + else: + self.logger.warning( + "Unknown item type in recommendation folder: %s", recommended_item + ) + continue + folders.append(folder) + return folders + async def _post_data(self, endpoint: str, data: dict[str, str], **kwargs): """Post data to the given endpoint.""" url = f"{YTM_BASE_URL}{endpoint}" diff --git a/music_assistant/providers/ytmusic/constants.py b/music_assistant/providers/ytmusic/constants.py new file mode 100644 index 00000000..abcc54d3 --- /dev/null +++ b/music_assistant/providers/ytmusic/constants.py @@ -0,0 +1,15 @@ +"""Constants for YT Music provider.""" + +from enum import StrEnum + + +class YTMRecommendationIcons(StrEnum): + """Icons for YTM recommendation types.""" + + LISTEN_AGAIN = "mdi-book-refresh-outline" + CONTINUE_WATCHING = "mdi-clock-outline" + DISCOVER = "mdi-magnify" + YOUR_MIX = "mdi-music-circle-outline" + NEW_RELEASES = "mdi-new-box" + RECOMMENDED = "mdi-star-circle-outline" + DEFAULT = "mdi-music-note-outline" diff --git a/music_assistant/providers/ytmusic/helpers.py b/music_assistant/providers/ytmusic/helpers.py index 0ba8f1ea..6c5aa15d 100644 --- a/music_assistant/providers/ytmusic/helpers.py +++ b/music_assistant/providers/ytmusic/helpers.py @@ -12,6 +12,8 @@ from time import time import ytmusicapi +from music_assistant.providers.ytmusic.constants import YTMRecommendationIcons + async def get_artist( prov_artist_id: str, headers: dict[str, str], language: str = "en" @@ -353,3 +355,32 @@ def convert_to_netscape(raw_cookie_str: str, domain: str) -> str: for morsel in cookie.values(): netscape_cookie += f"{domain}\tTRUE\t/\tTRUE\t0\t{morsel.key}\t{morsel.value}\n" return netscape_cookie + + +async def get_home( + headers: dict[str, str], language: str = "en", user: str | None = None, limit: int = 3 +) -> dict[str, str]: + """Get the recommendations from the home page.""" + + def _get_home(): + ytm = ytmusicapi.YTMusic(auth=headers, language=language, user=user) + return ytm.get_home(limit=limit) + + return await asyncio.to_thread(_get_home) + + +def determine_recommendation_icon(name: str) -> str: + """Determine the icon for a recommendation based on its name.""" + query = name.lower() + + if "listen again" in query: + return YTMRecommendationIcons.LISTEN_AGAIN + if "continue" in query: + return YTMRecommendationIcons.CONTINUE_WATCHING + if "your mix" in query: + return YTMRecommendationIcons.YOUR_MIX + if "new" in query: + return YTMRecommendationIcons.NEW_RELEASES + if "recommended" in query: + return YTMRecommendationIcons.RECOMMENDED + return YTMRecommendationIcons.DEFAULT