fix: Recommendations for ABS and iTunes Podcasts (#2086)
authorFabian Munkes <105975993+fmunkes@users.noreply.github.com>
Mon, 31 Mar 2025 22:02:56 +0000 (00:02 +0200)
committerGitHub <noreply@github.com>
Mon, 31 Mar 2025 22:02:56 +0000 (00:02 +0200)
* fix: itunes_podcasts - recommendations

* fix: abs recommendations

* exclude only word

music_assistant/providers/audiobookshelf/__init__.py
music_assistant/providers/itunes_podcasts/__init__.py
music_assistant/providers/itunes_podcasts/schema.py
pyproject.toml

index 9b8f29f062d25351f297968be54447bacb2d8929..261247d8f1770ece83654a7fdc18ccec41ce2db5 100644 (file)
@@ -225,6 +225,17 @@ class Audiobookshelf(MusicProvider):
         )
         if cached_libraries is None:
             self.libraries = LibrariesHelper()
+            # We need the library ids for recommendations. If the cache got cleared e.g. by a db
+            # migration, we might end up with empty library helpers on a configured provider. Note,
+            # that the lib item ids are not synced, still only on full provider sync, instead the
+            # sets are empty. Full sync is expensive.
+            # See warning in browse_lib_podcasts / _browse_books
+            libraries = await self._client.get_all_libraries()
+            for library in libraries:
+                if library.media_type == AbsLibraryMediaType.BOOK:
+                    self.libraries.audiobooks[library.id_] = LibraryHelper(name=library.name)
+                elif library.media_type == AbsLibraryMediaType.PODCAST:
+                    self.libraries.podcasts[library.id_] = LibraryHelper(name=library.name)
         else:
             self.libraries = LibrariesHelper.from_dict(cached_libraries)
 
@@ -267,6 +278,8 @@ class Audiobookshelf(MusicProvider):
     async def sync_library(self, media_type: MediaType) -> None:
         """Obtain audiobook library ids and podcast library ids."""
         libraries = await self._client.get_all_libraries()
+        if len(libraries) == 0:
+            self._log_no_libraries()
         for library in libraries:
             if library.media_type == AbsLibraryMediaType.BOOK and media_type == MediaType.AUDIOBOOK:
                 self.libraries.audiobooks[library.id_] = LibraryHelper(name=library.name)
@@ -571,7 +584,13 @@ class Audiobookshelf(MusicProvider):
 
         all_libraries = {**self.libraries.audiobooks, **self.libraries.podcasts}
         max_items_per_row = 20
-        limit_items_per_lib = max_items_per_row // len(all_libraries)
+        num_libraries = len(all_libraries)
+
+        if num_libraries == 0:
+            self._log_no_libraries()
+            return []
+
+        limit_items_per_lib = max_items_per_row // num_libraries
         limit_items_per_lib = 1 if limit_items_per_lib == 0 else limit_items_per_lib
 
         for library_id in all_libraries:
@@ -924,6 +943,10 @@ class Audiobookshelf(MusicProvider):
                 path=f"{self.instance_id}://{path}",
             )
 
+        if len(self.libraries.audiobooks) == 0 and len(self.libraries.podcasts) == 0:
+            self._log_no_libraries()
+            return []
+
         for lib_id, lib in self.libraries.audiobooks.items():
             path = f"{AbsBrowsePaths.LIBRARIES_BOOK} {lib_id}"
             if append_mediatype_suffix:
@@ -942,6 +965,8 @@ class Audiobookshelf(MusicProvider):
 
     async def _browse_lib_podcasts(self, library_id: str) -> list[MediaItemTypeOrItemMapping]:
         """No sub categories for podcasts."""
+        if len(self.libraries.podcasts[library_id].item_ids) == 0:
+            self._log_no_helper_item_ids()
         items = []
         for podcast_id in self.libraries.podcasts[library_id].item_ids:
             mass_item = await self.mass.music.get_library_item_by_prov_id(
@@ -1043,6 +1068,8 @@ class Audiobookshelf(MusicProvider):
         return sorted(items, key=lambda x: x.name)
 
     async def _browse_books(self, library_id: str) -> Sequence[MediaItemTypeOrItemMapping]:
+        if len(self.libraries.audiobooks[library_id].item_ids) == 0:
+            self._log_no_helper_item_ids()
         items = []
         for book_id in self.libraries.audiobooks[library_id].item_ids:
             mass_item = await self.mass.music.get_library_item_by_prov_id(
@@ -1341,3 +1368,12 @@ class Audiobookshelf(MusicProvider):
             category=CACHE_CATEGORY_LIBRARIES,
             data=self.libraries.to_dict(),
         )
+
+    def _log_no_libraries(self) -> None:
+        self.logger.error("There are no libraries visible to the Audiobookshelf provider.")
+
+    def _log_no_helper_item_ids(self) -> None:
+        self.logger.warning(
+            "Cached item ids are missing. "
+            "Please trigger a full resync of the Audiobookshelf provider manually."
+        )
index 0bdb110f20f4919b342a5952ecd9f7d50095d44c..d92f0be23fe57bc276c5784c20f12998059b59d3 100644 (file)
@@ -247,7 +247,7 @@ class ITunesPodcastsProvider(MusicProvider):
         for cnt, episode in enumerate(episodes):
             episode_enclosures = episode.get("enclosures", [])
             if len(episode_enclosures) < 1:
-                raise RuntimeError
+                raise MediaNotFoundError
             stream_url = episode_enclosures[0].get("url", None)
             if guid_or_stream_url == episode.get("guid", stream_url):
                 return parse_podcast_episode(
@@ -343,7 +343,7 @@ class ITunesPodcastsProvider(MusicProvider):
                 prov_podcast_id, headers={"User-Agent": "Mozilla/5.0"}
             )
             if response.status != 200:
-                raise RuntimeError
+                raise MediaNotFoundError
             feed_data = await response.read()
             feed_stream = BytesIO(feed_data)
             parsed_podcast = podcastparser.parse(
@@ -399,11 +399,25 @@ class ITunesPodcastsProvider(MusicProvider):
         if top_podcasts_response.feed is None:
             return []
 
+        include_explicit = bool(self.config.get_value(CONF_EXPLICIT))
+
         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_)
-            )
+            if not include_explicit and top_podcast.content_advisory_rating is not None:
+                # the spelling within the API is wrong.
+                if top_podcast.content_advisory_rating in [
+                    "explicit",
+                    "Explicit",
+                    "Explict",
+                    "explict",
+                ]:
+                    continue
+            try:
+                podcast_search_result = await self._get_podcast_search_result_from_itunes_id(
+                    int(top_podcast.id_)
+                )
+            except MediaNotFoundError:
+                continue
             helper.top_podcasts.append(podcast_search_result)
 
         await self._cache_set_top_podcasts(top_podcast_helper=helper)
index e9db2d74e06194362092e9ee1cd9d4c0c6899a6e..2256304c9e5a1be40187f77bd7496f2af84e3d00 100644 (file)
@@ -74,13 +74,16 @@ 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
+    id_: str | int = field(metadata=field_options(alias="id"), default="")
+    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)
+    content_advisory_rating: str | None = field(
+        metadata=field_options(alias="contentAdvisoryRating"), default=None
+    )
 
 
 @dataclass(kw_only=True)
index ee00cb82ee6b7dfab5b7fbbb8d77710bdb28e1b2..10fb59eb90eb5fe08b4f35a8a71d42521c9c9752 100644 (file)
@@ -60,7 +60,8 @@ test = [
 mass = "music_assistant.__main__:main"
 
 [tool.codespell]
-ignore-words-list = "provid,hass,followings,childs"
+# explicit is misspelled in the iTunes API
+ignore-words-list = "provid,hass,followings,childs,explict"
 skip = """*.js,*.svg,\
 music_assistant/providers/itunes_podcasts/itunes_country_codes.json,\
 """