diff --git a/music_assistant/controllers/media/audiobooks.py b/music_assistant/controllers/media/audiobooks.py index 8aff8e927b..8b344bd18e 100644 --- a/music_assistant/controllers/media/audiobooks.py +++ b/music_assistant/controllers/media/audiobooks.py @@ -6,6 +6,7 @@ from music_assistant_models.enums import MediaType, ProviderFeature from music_assistant_models.media_items import Audiobook, ProviderMapping, UniqueList +from music_assistant_models.media_items.helpers import AudiobookSeries from music_assistant.constants import DB_TABLE_AUDIOBOOKS, DB_TABLE_PLAYLOG from music_assistant.controllers.media.base import MediaControllerBase @@ -63,6 +64,7 @@ def __init__(self, mass: MusicAssistant) -> None: # register (extra) api handlers api_base = self.api_base self.mass.register_api_command(f"music/{api_base}/audiobook_versions", self.versions) + self.mass.register_api_command(f"music/{api_base}/get_series", self.series) async def library_items( self, @@ -73,6 +75,7 @@ async def library_items( order_by: str = "sort_name", provider: str | list[str] | None = None, genre: int | list[int] | None = None, + without_series: bool | None = None, **kwargs: Any, ) -> list[Audiobook]: """Get in-database audiobooks. @@ -84,12 +87,18 @@ async def library_items( :param order_by: Order by field (e.g. 'sort_name', 'timestamp_added'). :param provider: Filter by provider instance ID (single string or list). :param genre: Filter by genre id(s). + :param without_series: Do not return audiobooks which are part of a series """ extra_query_params: dict[str, Any] = {} extra_query_parts: list[str] = [] extra_join_parts: list[str] = [] if session_user := get_current_user(): extra_join_parts = [f"AND playlog.userid = '{session_user.user_id}'"] + if without_series: + extra_query_parts = [ + "WHERE (json_extract(audiobooks.metadata, '$.series') IS NULL " + "OR json_extract(audiobooks.metadata, '$.series') = '[]')", + ] result = await self.get_library_items_by_query( favorite=favorite, search=search, @@ -367,3 +376,53 @@ async def _set_playlog(self, db_id: int, media_item: Audiobook) -> None: }, allow_replace=True, ) + + async def series( + self, + ) -> list[AudiobookSeries]: + """Get all available audiobook series. + + :param limit: Maximum number of items to return. + :param offset: Number of items to skip. + """ + # key is the series' title + series_dict: dict[str, list[Audiobook]] = {} + audiobooks_with_series = await self.get_library_items_by_query( + extra_query_parts=[ + "WHERE json_extract(audiobooks.metadata, '$.series') IS NOT NULL " + "AND json_extract(audiobooks.metadata, '$.series') != '[]'", + ], + ) + for audiobook in audiobooks_with_series: + if audiobook.metadata.series is None: + # this should never happen + continue + for series_info in audiobook.metadata.series: + audiobook_list = series_dict.get(series_info.title, []) + audiobook_list.append(audiobook) + series_dict[series_info.title] = audiobook_list + + result: list[AudiobookSeries] = [] + # Sort series, first by number then alphabetically + for series_title, audiobook_list in series_dict.items(): + audiobooks_with_number: list[tuple[Audiobook, float]] = [] + audiobooks_with_string: list[tuple[Audiobook, str]] = [] + audiobooks_with_none: list[Audiobook] = [] + for audiobook in audiobook_list: + assert audiobook.metadata.series is not None # for type checking + series_info = next(x for x in audiobook.metadata.series if x.title == series_title) + if series_info.sequence is None: + audiobooks_with_none.append(audiobook) + continue + try: + sort_by = float(series_info.sequence) + audiobooks_with_number.append((audiobook, sort_by)) + except ValueError: + audiobooks_with_string.append((audiobook, str(series_info.sequence))) + final_list = [x[0] for x in sorted(audiobooks_with_number, key=lambda x: x[1])] + final_list.extend([x[0] for x in sorted(audiobooks_with_string, key=lambda x: x[1])]) + final_list.extend(audiobooks_with_none) + + result.append(AudiobookSeries(title=series_title, audiobooks=final_list)) + + return result diff --git a/music_assistant/providers/audiobookshelf/parsers.py b/music_assistant/providers/audiobookshelf/parsers.py index 5cd04828fb..3b5bfe5c61 100644 --- a/music_assistant/providers/audiobookshelf/parsers.py +++ b/music_assistant/providers/audiobookshelf/parsers.py @@ -31,6 +31,7 @@ ItemMapping, MediaItemChapter, MediaItemImage, + MediaItemSeries, ProviderMapping, UniqueList, ) @@ -221,7 +222,7 @@ def parse_podcast_episode( def parse_audiobook( *, - abs_audiobook: AbsLibraryItemExpandedBook | AbsLibraryItemMinifiedBook, + abs_audiobook: AbsLibraryItemExpandedBook, instance_id: str, domain: str, token: str | None, @@ -266,6 +267,15 @@ def parse_audiobook( year=int(abs_audiobook.media.metadata.published_year), month=1, day=1 ) + book_series: list[MediaItemSeries] = [] + for abs_series_sequence in abs_audiobook.media.metadata.series: + book_series.append( + MediaItemSeries(title=abs_series_sequence.name, sequence=abs_series_sequence.sequence) + ) + + if book_series: + mass_audiobook.metadata.series = UniqueList(book_series) + if abs_audiobook.media.metadata.genres is not None: mass_audiobook.metadata.genres = set(abs_audiobook.media.metadata.genres)