From 8f7362e7eb23b6b88683c0210dd102b965eb1df1 Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Sat, 29 Mar 2025 17:10:05 +0100 Subject: [PATCH] ABS: Enhancement - support for recommendations (#2074) --- .../providers/audiobookshelf/__init__.py | 176 +++++++++++++++++- .../providers/audiobookshelf/constants.py | 17 +- .../providers/audiobookshelf/parsers.py | 51 +++-- 3 files changed, 226 insertions(+), 18 deletions(-) diff --git a/music_assistant/providers/audiobookshelf/__init__.py b/music_assistant/providers/audiobookshelf/__init__.py index 7d53f372..b26248a6 100644 --- a/music_assistant/providers/audiobookshelf/__init__.py +++ b/music_assistant/providers/audiobookshelf/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import itertools from collections.abc import AsyncGenerator, Sequence from typing import TYPE_CHECKING @@ -11,6 +12,7 @@ from aioaudiobookshelf.client.items import ( LibraryItemExpandedPodcast as AbsLibraryItemExpandedPodcast, ) from aioaudiobookshelf.exceptions import LoginError as AbsLoginError +from aioaudiobookshelf.schema.author import AuthorExpanded from aioaudiobookshelf.schema.calls_authors import ( AuthorWithItemsAndSeries as AbsAuthorWithItemsAndSeries, ) @@ -22,6 +24,19 @@ from aioaudiobookshelf.schema.library import ( LibraryItemMinifiedPodcast, ) from aioaudiobookshelf.schema.library import LibraryMediaType as AbsLibraryMediaType +from aioaudiobookshelf.schema.shelf import ( + SeriesShelf, + ShelfAuthors, + ShelfBook, + ShelfEpisode, + ShelfLibraryItemMinified, + ShelfPodcast, + ShelfSeries, +) +from aioaudiobookshelf.schema.shelf import ( + ShelfId as AbsShelfId, +) +from aioaudiobookshelf.schema.shelf import ShelfType as AbsShelfType from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig from music_assistant_models.enums import ( ConfigEntryType, @@ -38,7 +53,9 @@ from music_assistant_models.media_items import ( MediaItemType, MediaItemTypeOrItemMapping, PodcastEpisode, + UniqueList, ) +from music_assistant_models.media_items.media_item import RecommendationFolder from music_assistant_models.streamdetails import StreamDetails from music_assistant.models.music_provider import MusicProvider @@ -49,7 +66,8 @@ from music_assistant.providers.audiobookshelf.parsers import ( ) from .constants import ( - ABSBROWSEITEMSTOPATH, + ABS_BROWSE_ITEMS_TO_PATH, + ABS_SHELF_ID_ICONS, CACHE_CATEGORY_LIBRARIES, CACHE_KEY_LIBRARIES, CONF_HIDE_EMPTY_PODCASTS, @@ -165,6 +183,7 @@ class Audiobookshelf(MusicProvider): ProviderFeature.LIBRARY_PODCASTS, ProviderFeature.LIBRARY_AUDIOBOOKS, ProviderFeature.BROWSE, + ProviderFeature.RECOMMENDATIONS, } async def handle_async_init(self) -> None: @@ -542,6 +561,159 @@ class Audiobookshelf(MusicProvider): return False, 0 + async def recommendations(self) -> list[RecommendationFolder]: + """Get recommendations.""" + # We have to avoid "flooding" the home page, which becomes especially troublesome if users + # have multiple libraries. Instead we collect per ShelfId, and make sure, that we always get + # roughly the same amount of items per row, no matter the amount of libraries + # List of list (one list per lib) here, such that we can pick the items per lib later. + items_by_shelf_id: dict[AbsShelfId, list[list[MediaItemType]]] = {} + + all_libraries = {**self.libraries.audiobooks, **self.libraries.podcasts} + max_items_per_row = 20 + limit_items_per_lib = max_items_per_row // len(all_libraries) + limit_items_per_lib = 1 if limit_items_per_lib == 0 else limit_items_per_lib + + for library_id in all_libraries: + shelves = await self._client.get_library_personalized_view( + library_id=library_id, limit=limit_items_per_lib + ) + await self._recommendations_iter_shelves(shelves, library_id, items_by_shelf_id) + + folders: list[RecommendationFolder] = [] + for shelf_id, item_lists in items_by_shelf_id.items(): + # we have something like [[A, B], [C, D, E], [F]] + # and want [A, C, F, B, D, E] + recommendation_items = [ + x + for x in itertools.chain.from_iterable(itertools.zip_longest(*item_lists)) + if x is not None + ][:max_items_per_row] + + # shelf ids follow pattern: + # recently-added + # newest-episodes + # etc + name = f"{shelf_id.capitalize().replace('-', ' ')}" + folders.append( + RecommendationFolder( + item_id=f"{shelf_id}", + name=name, + icon=ABS_SHELF_ID_ICONS.get(shelf_id), + # translation_key=shelf.id_, + items=UniqueList(recommendation_items), + provider=self.lookup_key, + ) + ) + + return folders + + async def _recommendations_iter_shelves( + self, + shelves: list[ShelfBook | ShelfPodcast | ShelfAuthors | ShelfEpisode | ShelfSeries], + library_id: str, + items_by_shelf_id: dict[AbsShelfId, list[list[MediaItemType]]], + ) -> None: + for shelf in shelves: + media_type: MediaType + match shelf.type_: + case AbsShelfType.PODCAST: + media_type = MediaType.PODCAST + case AbsShelfType.EPISODE: + media_type = MediaType.PODCAST_EPISODE + case AbsShelfType.BOOK: + media_type = MediaType.AUDIOBOOK + case AbsShelfType.SERIES | AbsShelfType.AUTHORS: + media_type = MediaType.FOLDER + case _: + # this would be authors, currently + continue + + items: list[MediaItemType] = [] + # Recently added is the _only_ case, where we get a full podcast + # We have a podcast object with only the episodes matching the + # shelf.id_ otherwise. + match shelf.id_: + case ( + AbsShelfId.RECENTLY_ADDED + | AbsShelfId.LISTEN_AGAIN + | AbsShelfId.DISCOVER + | AbsShelfId.NEWEST_EPISODES + | AbsShelfId.CONTINUE_LISTENING + ): + for entity in shelf.entities: + assert isinstance(entity, ShelfLibraryItemMinified) + item: MediaItemType | None = None + if media_type in [MediaType.PODCAST, MediaType.AUDIOBOOK]: + item = await self.mass.music.get_library_item_by_prov_id( + media_type=media_type, + provider_instance_id_or_domain=self.instance_id, + item_id=entity.id_, + ) + elif media_type == MediaType.PODCAST_EPISODE: + podcast_id = entity.id_ + if entity.recent_episode is None: + continue + # we only have a PodcastEpisode here, with limited information + item = parse_podcast_episode( + episode=entity.recent_episode, + prov_podcast_id=podcast_id, + lookup_key=self.lookup_key, + domain=self.domain, + instance_id=self.instance_id, + token=self._client.token, + base_url=str(self.config.get_value(CONF_URL)).rstrip("/"), + ) + if item is not None: + items.append(item) + case AbsShelfId.RECENT_SERIES | AbsShelfId.CONTINUE_SERIES: + # we jump into a browse folder here, set path up as if browse function + # used. + for entity in shelf.entities: + assert isinstance(entity, SeriesShelf) + if len(entity.books) == 0: + continue + path = ( + f"{self.instance_id}://" + f"{AbsBrowsePaths.LIBRARIES_BOOK} {library_id}/" + f"{AbsBrowsePaths.SERIES}/{entity.id_}" + ) + items.append( + BrowseFolder( + item_id=entity.id_, + name=entity.name, + provider=self.lookup_key, + path=path, + ) + ) + case AbsShelfId.NEWEST_AUTHORS: + # same as for series, use a folder + for entity in shelf.entities: + assert isinstance(entity, AuthorExpanded) + if entity.num_books == 0: + continue + path = ( + f"{self.instance_id}://" + f"{AbsBrowsePaths.LIBRARIES_BOOK} {library_id}/" + f"{AbsBrowsePaths.AUTHORS}/{entity.id_}" + ) + items.append( + BrowseFolder( + item_id=entity.id_, + name=entity.name, + provider=self.lookup_key, + path=path, + ) + ) + if not items: + continue + + # add collected items + assert isinstance(shelf.id_, AbsShelfId) + items_collected = items_by_shelf_id.get(shelf.id_, []) + items_collected.append(items) + items_by_shelf_id[shelf.id_] = items_collected + async def on_played( self, media_type: MediaType, @@ -733,7 +905,7 @@ class Audiobookshelf(MusicProvider): def _browse_lib_audiobooks(self, current_path: str) -> Sequence[MediaItemTypeOrItemMapping]: items = [] for item_name in AbsBrowseItemsBook: - path = current_path + "/" + ABSBROWSEITEMSTOPATH[item_name] + path = current_path + "/" + ABS_BROWSE_ITEMS_TO_PATH[item_name] items.append( BrowseFolder( item_id=item_name.lower(), diff --git a/music_assistant/providers/audiobookshelf/constants.py b/music_assistant/providers/audiobookshelf/constants.py index 9aba4e6f..fd659de8 100644 --- a/music_assistant/providers/audiobookshelf/constants.py +++ b/music_assistant/providers/audiobookshelf/constants.py @@ -2,6 +2,8 @@ from enum import StrEnum +from aioaudiobookshelf.schema.shelf import ShelfId as AbsShelfId + # CONFIG CONF_URL = "url" CONF_USERNAME = "username" @@ -45,10 +47,23 @@ class AbsBrowseItemsPodcast(StrEnum): PODCASTS = "Podcasts" -ABSBROWSEITEMSTOPATH: dict[str, str] = { +ABS_BROWSE_ITEMS_TO_PATH: dict[str, str] = { AbsBrowseItemsBook.AUTHORS: AbsBrowsePaths.AUTHORS, AbsBrowseItemsBook.NARRATORS: AbsBrowsePaths.NARRATORS, AbsBrowseItemsBook.SERIES: AbsBrowsePaths.SERIES, AbsBrowseItemsBook.COLLECTIONS: AbsBrowsePaths.COLLECTIONS, AbsBrowseItemsBook.AUDIOBOOKS: AbsBrowsePaths.AUDIOBOOKS, } + +ABS_SHELF_ID_ICONS: dict[str, str] = { + AbsShelfId.LISTEN_AGAIN: "mdi-book-refresh-outline", + AbsShelfId.CONTINUE_LISTENING: "mdi-clock-outline", + AbsShelfId.CONTINUE_SERIES: "mdi-play-box-multiple-outline", + AbsShelfId.RECOMMENDED: "mdi-lightbulb-outline", + AbsShelfId.RECENTLY_ADDED: "mdi-plus-box-multiple-outline", + AbsShelfId.EPISODES_RECENTLY_ADDED: "mdi-plus-box-multiple-outline", + AbsShelfId.RECENT_SERIES: "mdi-bookshelf", + AbsShelfId.NEWEST_AUTHORS: "mdi-plus-box-multiple-outline", + AbsShelfId.NEWEST_EPISODES: "mdi-plus-box-multiple-outline", + AbsShelfId.DISCOVER: "mdi-magnify", +} diff --git a/music_assistant/providers/audiobookshelf/parsers.py b/music_assistant/providers/audiobookshelf/parsers.py index 19ac868f..6c9137d8 100644 --- a/music_assistant/providers/audiobookshelf/parsers.py +++ b/music_assistant/providers/audiobookshelf/parsers.py @@ -19,7 +19,10 @@ from aioaudiobookshelf.schema.library import ( LibraryItemPodcast as AbsLibraryItemPodcast, ) from aioaudiobookshelf.schema.media_progress import MediaProgress as AbsMediaProgress -from aioaudiobookshelf.schema.podcast import PodcastEpisodeExpanded as AbsPodcastEpisodeExpanded +from aioaudiobookshelf.schema.podcast import PodcastEpisode as AbsPodcastEpisode +from aioaudiobookshelf.schema.podcast import ( + PodcastEpisodeExpanded as AbsPodcastEpisodeExpanded, +) from music_assistant_models.enums import ContentType, ImageType, MediaType from music_assistant_models.media_items import Audiobook as MassAudiobook from music_assistant_models.media_items import ( @@ -92,7 +95,7 @@ def parse_podcast( def parse_podcast_episode( *, - episode: AbsPodcastEpisodeExpanded, + episode: AbsPodcastEpisode | AbsPodcastEpisodeExpanded, prov_podcast_id: str, fallback_episode_cnt: int | None = None, lookup_key: str, @@ -107,10 +110,38 @@ def parse_podcast_episode( For an episode the id is set to f"{podcast_id} {episode_id}". ABS ids have no spaces, so we can split at a space to retrieve both in other functions. + + NOTE: We should always use a PodcastEpisodeExpanded when possible. + A PodcastEpisode has only limited information, and is currently only used + within the recommendations. """ - url = f"{base_url}{episode.audio_track.content_url}" episode_id = f"{prov_podcast_id} {episode.id_}" + if isinstance(episode, AbsPodcastEpisodeExpanded): + url = f"{base_url}{episode.audio_track.content_url}" + duration = int(episode.duration) + provider_mappings = { + ProviderMapping( + item_id=episode_id, + provider_domain=domain, + provider_instance=instance_id, + audio_format=AudioFormat( + content_type=ContentType.UNKNOWN, + ), + url=url, + ) + } + else: + # PodcastEpisode + duration = 0 # mass default + provider_mappings = { + ProviderMapping( + item_id=episode_id, + provider_domain=domain, + provider_instance=instance_id, + ) + } + release_date: datetime | None = None if episode.published_at is not None: position = -episode.published_at @@ -124,7 +155,7 @@ def parse_podcast_episode( item_id=episode_id, provider=lookup_key, name=episode.title, - duration=int(episode.duration), + duration=duration, position=position, podcast=ItemMapping( item_id=prov_podcast_id, @@ -132,17 +163,7 @@ def parse_podcast_episode( name=episode.title, media_type=MediaType.PODCAST, ), - provider_mappings={ - ProviderMapping( - item_id=episode_id, - provider_domain=domain, - provider_instance=instance_id, - audio_format=AudioFormat( - content_type=ContentType.UNKNOWN, - ), - url=url, - ) - }, + provider_mappings=provider_mappings, ) mass_episode.metadata.release_date = release_date -- 2.34.1