Add recommendations to itunes_podcasts (#2076)
authorFabian Munkes <105975993+fmunkes@users.noreply.github.com>
Sun, 30 Mar 2025 13:28:02 +0000 (15:28 +0200)
committerGitHub <noreply@github.com>
Sun, 30 Mar 2025 13:28:02 +0000 (15:28 +0200)
itunes_podcasts: recommendations

music_assistant/helpers/podcast_parsers.py
music_assistant/providers/itunes_podcasts/__init__.py
music_assistant/providers/itunes_podcasts/schema.py

index 9ed3079181c5894ef591434dd5516f8f9823adf9..216dd2447e0281c405dace5e2e7db219cd4ddb4f 100644 (file)
@@ -150,7 +150,7 @@ def parse_podcast_episode(
     # chapter
     if chapters := episode.get("chapters"):
         _chapters = []
-        for cnt, chapter in chapters:
+        for cnt, chapter in enumerate(chapters):
             if not isinstance(chapter, dict):
                 continue
             title = chapter.get("title")
index 0b124f7ed14a7d2a4e6f2ac47d05be901a03694e..0bdb110f20f4919b342a5952ecd9f7d50095d44c 100644 (file)
@@ -26,6 +26,7 @@ from music_assistant_models.media_items import (
     Podcast,
     PodcastEpisode,
     ProviderMapping,
+    RecommendationFolder,
     SearchResults,
     UniqueList,
 )
@@ -34,7 +35,12 @@ from music_assistant_models.streamdetails import StreamDetails
 from music_assistant.helpers.podcast_parsers import parse_podcast, parse_podcast_episode
 from music_assistant.helpers.throttle_retry import ThrottlerManager, throttle_with_retries
 from music_assistant.models.music_provider import MusicProvider
-from music_assistant.providers.itunes_podcasts.schema import ITunesSearchResults
+from music_assistant.providers.itunes_podcasts.schema import (
+    ITunesSearchResults,
+    PodcastSearchResult,
+    TopPodcastsHelper,
+    TopPodcastsResponse,
+)
 
 if TYPE_CHECKING:
     from music_assistant_models.config_entries import ConfigValueType, ProviderConfig
@@ -49,6 +55,8 @@ CONF_EXPLICIT = "explicit"
 CONF_NUM_EPISODES = "num_episodes"
 
 CACHE_CATEGORY_PODCASTS = 0
+CACHE_CATEGORY_RECOMMENDATIONS = 1
+CACHE_KEY_TOP_PODCASTS = "top-podcasts"
 
 
 async def setup(
@@ -112,9 +120,7 @@ class ITunesPodcastsProvider(MusicProvider):
     @property
     def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
-        return {
-            ProviderFeature.SEARCH,
-        }
+        return {ProviderFeature.SEARCH, ProviderFeature.RECOMMENDATIONS}
 
     @property
     def is_streaming_provider(self) -> bool:
@@ -164,8 +170,11 @@ class ITunesPodcastsProvider(MusicProvider):
             json_response = await response.read()
         if not json_response:
             return []
-        podcast_list: list[Podcast] = []
         results = ITunesSearchResults.from_json(json_response).results
+        return self._get_podcast_list(results)
+
+    def _get_podcast_list(self, results: list[PodcastSearchResult]) -> list[Podcast]:
+        podcast_list: list[Podcast] = []
         for result in results:
             if result.feed_url is None or result.track_name is None:
                 continue
@@ -253,6 +262,24 @@ class ITunesPodcastsProvider(MusicProvider):
 
         raise MediaNotFoundError("Episode not found")
 
+    async def recommendations(self) -> list[RecommendationFolder]:
+        """Get recommendations.
+
+        This provider uses a list of top podcasts for the configured country.
+        """
+        search_results = await self._cache_get_top_podcasts()
+        podcast_list = self._get_podcast_list(search_results)
+        return [
+            RecommendationFolder(
+                item_id="itunes-top-podcasts",
+                name="Trending podcasts",
+                icon="mdi-trending-up",
+                # translation_key=shelf.id_,
+                items=UniqueList(podcast_list),
+                provider=self.lookup_key,
+            )
+        ]
+
     async def _get_episode_stream_url(self, podcast_id: str, guid_or_stream_url: str) -> str | None:
         podcast = await self._cache_get_podcast(podcast_id)
         episodes = podcast.get("episodes", [])
@@ -284,6 +311,25 @@ class ITunesPodcastsProvider(MusicProvider):
             allow_seek=True,
         )
 
+    @throttle_with_retries
+    async def _get_podcast_search_result_from_itunes_id(
+        self, itunes_id: int
+    ) -> PodcastSearchResult:
+        params = {"id": itunes_id}
+        url = "https://itunes.apple.com/lookup?"
+        response = await self.mass.http_session.get(url, params=params)
+        json_response = b""
+        if response.status == 200:
+            json_response = await response.read()
+        if not json_response:
+            raise MediaNotFoundError
+        search_results = ITunesSearchResults.from_json(json_response)
+        if search_results.result_count == 0:
+            raise MediaNotFoundError
+        if search_results.result_count > 1:
+            self.logger.warning("More than a single result for podcast.")
+        return search_results.results[0]
+
     async def _cache_get_podcast(self, prov_podcast_id: str) -> dict[str, Any]:
         parsed_podcast = await self.mass.cache.get(
             key=prov_podcast_id,
@@ -316,3 +362,49 @@ class ITunesPodcastsProvider(MusicProvider):
             data=parsed_podcast,
             expiration=60 * 60 * 24,  # 1 day
         )
+
+    async def _cache_set_top_podcasts(self, top_podcast_helper: TopPodcastsHelper) -> None:
+        await self.mass.cache.set(
+            key=CACHE_KEY_TOP_PODCASTS,
+            base_key=self.lookup_key,
+            category=CACHE_CATEGORY_RECOMMENDATIONS,
+            data=top_podcast_helper.to_dict(),
+            expiration=60 * 60 * 6,  # 6 hours
+        )
+
+    async def _cache_get_top_podcasts(self) -> list[PodcastSearchResult]:
+        parsed_top_podcasts = await self.mass.cache.get(
+            key=CACHE_KEY_TOP_PODCASTS,
+            base_key=self.lookup_key,
+            category=CACHE_CATEGORY_RECOMMENDATIONS,
+        )
+        if parsed_top_podcasts is not None:
+            helper = TopPodcastsHelper.from_dict(parsed_top_podcasts)
+            return helper.top_podcasts
+
+        # 15 results
+        # keep 20 requests max per minute in mind
+        # https://rss.marketingtools.apple.com/
+        country = str(self.config.get_value(CONF_LOCALE))
+        url = f"https://rss.marketingtools.apple.com/api/v2/{country}/podcasts/top/15/podcasts.json"
+        response = await self.mass.http_session.get(url)
+        json_response = b""
+        if response.status == 200:
+            json_response = await response.read()
+        if not json_response:
+            return []
+
+        top_podcasts_response = TopPodcastsResponse.from_json(json_response)
+
+        if top_podcasts_response.feed is None:
+            return []
+
+        helper = TopPodcastsHelper()
+        for top_podcast in top_podcasts_response.feed.results:
+            podcast_search_result = await self._get_podcast_search_result_from_itunes_id(
+                int(top_podcast.id_)
+            )
+            helper.top_podcasts.append(podcast_search_result)
+
+        await self._cache_set_top_podcasts(top_podcast_helper=helper)
+        return helper.top_podcasts
index 2f38d95471ab11e23535b7aff8252e141b3456ee..e9db2d74e06194362092e9ee1cd9d4c0c6899a6e 100644 (file)
@@ -56,3 +56,54 @@ class ITunesSearchResults(_BaseModel):
 
     result_count: int = field(metadata=field_options(alias="resultCount"), default=0)
     results: list[PodcastSearchResult] = field(default_factory=list)
+
+
+# below is only what we need
+
+
+@dataclass(kw_only=True)
+class TopPodcastsGenres(_BaseModel):
+    """TopPodcastsGenres."""
+
+    genre_id: str | int = field(metadata=field_options(alias="genreId"), default="")
+    name: str
+
+
+@dataclass(kw_only=True)
+class TopPodcastsResult(_BaseModel):
+    """TopPodcastsResult."""
+
+    artist_name: str = field(metadata=field_options(alias="artistName"), default="")
+    id_: str | int = field(metadata=field_options(alias="id"))
+    name: str
+    genres: list[TopPodcastsGenres] = field(default_factory=list)
+    artwork_url_30: str | None = field(metadata=field_options(alias="artworkUrl30"), default=None)
+    artwork_url_60: str | None = field(metadata=field_options(alias="artworkUrl60"), default=None)
+    artwork_url_100: str | None = field(metadata=field_options(alias="artworkUrl100"), default=None)
+    artwork_url_600: str | None = field(metadata=field_options(alias="artworkUrl600"), default=None)
+
+
+@dataclass(kw_only=True)
+class TopPodcastsResults(_BaseModel):
+    """TopPodcastsResults."""
+
+    country: str
+    results: list[TopPodcastsResult] = field(default_factory=list)
+
+
+@dataclass(kw_only=True)
+class TopPodcastsResponse(_BaseModel):
+    """TopPodcastsResponse."""
+
+    feed: TopPodcastsResults | None = None
+
+
+# HELPER
+@dataclass(kw_only=True)
+class TopPodcastsHelper(_BaseModel):
+    """TopPodcastsHelper.
+
+    This is used to cache the recommendations.
+    """
+
+    top_podcasts: list[PodcastSearchResult] = field(default_factory=list)