YTMusic: Add recommendations (#2128)
authorMarvin Schenkel <marvinschenkel@gmail.com>
Tue, 15 Apr 2025 16:45:04 +0000 (18:45 +0200)
committerGitHub <noreply@github.com>
Tue, 15 Apr 2025 16:45:04 +0000 (18:45 +0200)
music_assistant/providers/ytmusic/__init__.py
music_assistant/providers/ytmusic/constants.py [new file with mode: 0644]
music_assistant/providers/ytmusic/helpers.py

index 9f9b1686b48bfdd4aa37b73cfe74b0f74a9a8031..89f0e1e2d424f139f0bcc91b25354c549a692f8b 100644 (file)
@@ -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 (file)
index 0000000..abcc54d
--- /dev/null
@@ -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"
index 0ba8f1ea18731bd0ea5b963c78a141f5b43dd6d5..6c5aa15d481bb409639d695f082e12625b60346e 100644 (file)
@@ -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