From 839f30f01e2b26a23e098133a3b5ff73aaf0f570 Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Tue, 1 Apr 2025 00:02:56 +0200 Subject: [PATCH] fix: Recommendations for ABS and iTunes Podcasts (#2086) * fix: itunes_podcasts - recommendations * fix: abs recommendations * exclude only word --- .../providers/audiobookshelf/__init__.py | 38 ++++++++++++++++++- .../providers/itunes_podcasts/__init__.py | 24 +++++++++--- .../providers/itunes_podcasts/schema.py | 7 +++- pyproject.toml | 3 +- 4 files changed, 63 insertions(+), 9 deletions(-) diff --git a/music_assistant/providers/audiobookshelf/__init__.py b/music_assistant/providers/audiobookshelf/__init__.py index 9b8f29f0..261247d8 100644 --- a/music_assistant/providers/audiobookshelf/__init__.py +++ b/music_assistant/providers/audiobookshelf/__init__.py @@ -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." + ) diff --git a/music_assistant/providers/itunes_podcasts/__init__.py b/music_assistant/providers/itunes_podcasts/__init__.py index 0bdb110f..d92f0be2 100644 --- a/music_assistant/providers/itunes_podcasts/__init__.py +++ b/music_assistant/providers/itunes_podcasts/__init__.py @@ -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) diff --git a/music_assistant/providers/itunes_podcasts/schema.py b/music_assistant/providers/itunes_podcasts/schema.py index e9db2d74..2256304c 100644 --- a/music_assistant/providers/itunes_podcasts/schema.py +++ b/music_assistant/providers/itunes_podcasts/schema.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index ee00cb82..10fb59eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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,\ """ -- 2.34.1