Podcast,
PodcastEpisode,
ProviderMapping,
+ RecommendationFolder,
SearchResults,
Track,
)
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,
ProviderFeature.ARTIST_TOPTRACKS,
ProviderFeature.SIMILAR_TRACKS,
ProviderFeature.LIBRARY_PODCASTS,
+ ProviderFeature.RECOMMENDATIONS,
}
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}"
--- /dev/null
+"""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"
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"
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