From 1b1bdecf1cab5ed3656f4a5a105a60a4081fea92 Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Tue, 21 Jan 2025 22:20:14 +0100 Subject: [PATCH] Chore: Audiobookshelf - adapt schema to reflect the naming scheme used in the API docs (#1898) --- .../providers/audiobookshelf/__init__.py | 18 +- .../providers/audiobookshelf/abs_client.py | 53 +- .../providers/audiobookshelf/abs_schema.py | 767 ++++++++++++------ 3 files changed, 586 insertions(+), 252 deletions(-) diff --git a/music_assistant/providers/audiobookshelf/__init__.py b/music_assistant/providers/audiobookshelf/__init__.py index eae17c60..353a8fe6 100644 --- a/music_assistant/providers/audiobookshelf/__init__.py +++ b/music_assistant/providers/audiobookshelf/__init__.py @@ -36,11 +36,11 @@ from music_assistant_models.streamdetails import StreamDetails from music_assistant.models.music_provider import MusicProvider from music_assistant.providers.audiobookshelf.abs_client import ABSClient from music_assistant.providers.audiobookshelf.abs_schema import ( - ABSAudioBook, ABSDeviceInfo, ABSLibrary, + ABSLibraryItemExpandedBook, + ABSLibraryItemExpandedPodcast, ABSPlaybackSessionExpanded, - ABSPodcast, ABSPodcastEpisodeExpanded, ) @@ -174,7 +174,7 @@ class Audiobookshelf(MusicProvider): await self._client.sync() await super().sync_library(media_types=media_types) - def _parse_podcast(self, abs_podcast: ABSPodcast) -> Podcast: + def _parse_podcast(self, abs_podcast: ABSLibraryItemExpandedPodcast) -> Podcast: """Translate ABSPodcast to MassPodcast.""" title = abs_podcast.media.metadata.title # Per API doc title may be None. @@ -185,7 +185,7 @@ class Audiobookshelf(MusicProvider): name=title, publisher=abs_podcast.media.metadata.author, provider=self.lookup_key, - total_episodes=abs_podcast.media.num_episodes, + total_episodes=len(abs_podcast.media.episodes), provider_mappings={ ProviderMapping( item_id=abs_podcast.id_, @@ -309,7 +309,7 @@ class Audiobookshelf(MusicProvider): episode_cnt += 1 raise MediaNotFoundError("Episode not found") - async def _parse_audiobook(self, abs_audiobook: ABSAudioBook) -> Audiobook: + async def _parse_audiobook(self, abs_audiobook: ABSLibraryItemExpandedBook) -> Audiobook: mass_audiobook = Audiobook( item_id=abs_audiobook.id_, provider=self.lookup_key, @@ -431,7 +431,9 @@ class Audiobookshelf(MusicProvider): return await self._get_stream_details_audiobook(abs_audiobook) raise MediaNotFoundError("Stream unknown") - async def _get_stream_details_audiobook(self, abs_audiobook: ABSAudioBook) -> StreamDetails: + async def _get_stream_details_audiobook( + self, abs_audiobook: ABSLibraryItemExpandedBook + ) -> StreamDetails: """Only single audio file in audiobook.""" self.logger.debug( f"Using direct playback for audiobook {abs_audiobook.media.metadata.title}" @@ -554,7 +556,9 @@ class Audiobookshelf(MusicProvider): if library is None: raise MediaNotFoundError("Lib missing.") - def get_item_mapping(item: ABSAudioBook | ABSPodcast) -> ItemMapping: + def get_item_mapping( + item: ABSLibraryItemExpandedBook | ABSLibraryItemExpandedPodcast, + ) -> ItemMapping: title = item.media.metadata.title if title is None: title = "UNKNOWN" diff --git a/music_assistant/providers/audiobookshelf/abs_client.py b/music_assistant/providers/audiobookshelf/abs_client.py index b4fa79ca..37598815 100644 --- a/music_assistant/providers/audiobookshelf/abs_client.py +++ b/music_assistant/providers/audiobookshelf/abs_client.py @@ -12,18 +12,20 @@ from aiohttp import ClientSession from music_assistant_models.media_items import UniqueList from music_assistant.providers.audiobookshelf.abs_schema import ( - ABSAudioBook, ABSDeviceInfo, - ABSLibrariesItemsResponse, + ABSLibrariesItemsMinifiedBookResponse, + ABSLibrariesItemsMinifiedPodcastResponse, ABSLibrariesResponse, ABSLibrary, - ABSLibraryItem, + ABSLibraryItemExpandedBook, + ABSLibraryItemExpandedPodcast, + ABSLibraryItemMinifiedBook, + ABSLibraryItemMinifiedPodcast, ABSLoginResponse, ABSMediaProgress, ABSPlaybackSession, ABSPlaybackSessionExpanded, ABSPlayRequest, - ABSPodcast, ABSSessionsResponse, ABSSessionUpdate, ABSUser, @@ -163,31 +165,42 @@ class ABSClient: self.podcast_libraries.append(library) self.user = await self.get_authenticated_user() - async def get_all_podcasts(self) -> AsyncGenerator[ABSPodcast]: + async def get_all_podcasts(self) -> AsyncGenerator[ABSLibraryItemExpandedPodcast]: """Get all available podcasts.""" for library in self.podcast_libraries: async for podcast in self.get_all_podcasts_by_library(library): yield podcast async def _get_lib_items(self, lib: ABSLibrary) -> AsyncGenerator[bytes]: - """Get library items with pagination.""" + """Get library items with pagination. + + Note: + - minified=1 -> minified items. However, there appears to be + a bug in abs, so we always get minified items. Still there for + consistency + - collapseseries=0 -> even if books are part of a series, they will be single items + """ page_cnt = 0 while True: data = await self._get( - f"/libraries/{lib.id_}/items", + f"/libraries/{lib.id_}/items?minified=1&collapseseries=0", params={"limit": LIMIT_ITEMS_PER_PAGE, "page": page_cnt}, ) page_cnt += 1 yield data - async def get_all_podcasts_by_library(self, lib: ABSLibrary) -> AsyncGenerator[ABSPodcast]: + async def get_all_podcasts_by_library( + self, lib: ABSLibrary + ) -> AsyncGenerator[ABSLibraryItemExpandedPodcast]: """Get all podcasts in a library.""" async for podcast_data in self._get_lib_items(lib): - podcast_list = ABSLibrariesItemsResponse.from_json(podcast_data).results + podcast_list = ABSLibrariesItemsMinifiedPodcastResponse.from_json(podcast_data).results if not podcast_list: # [] if page exceeds return - async def _get_id(plist: list[ABSLibraryItem] = podcast_list) -> AsyncGenerator[str]: + async def _get_id( + plist: list[ABSLibraryItemMinifiedPodcast] = podcast_list, + ) -> AsyncGenerator[str]: for entry in plist: yield entry.id_ @@ -195,11 +208,11 @@ class ABSClient: podcast = await self.get_podcast(id_) yield podcast - async def get_podcast(self, id_: str) -> ABSPodcast: + async def get_podcast(self, id_: str) -> ABSLibraryItemExpandedPodcast: """Get a single Podcast by ID.""" # this endpoint gives more podcast extra data data = await self._get(f"items/{id_}?expanded=1") - return ABSPodcast.from_json(data) + return ABSLibraryItemExpandedPodcast.from_json(data) async def _get_progress_ms( self, @@ -288,20 +301,24 @@ class ABSClient: endpoint = f"me/progress/{audiobook_id}" await self._update_progress(endpoint, progress_s, duration_s, is_finished) - async def get_all_audiobooks(self) -> AsyncGenerator[ABSAudioBook]: + async def get_all_audiobooks(self) -> AsyncGenerator[ABSLibraryItemExpandedBook]: """Get all audiobooks.""" for library in self.audiobook_libraries: async for book in self.get_all_audiobooks_by_library(library): yield book - async def get_all_audiobooks_by_library(self, lib: ABSLibrary) -> AsyncGenerator[ABSAudioBook]: + async def get_all_audiobooks_by_library( + self, lib: ABSLibrary + ) -> AsyncGenerator[ABSLibraryItemExpandedBook]: """Get all Audiobooks in a library.""" async for audiobook_data in self._get_lib_items(lib): - audiobook_list = ABSLibrariesItemsResponse.from_json(audiobook_data).results + audiobook_list = ABSLibrariesItemsMinifiedBookResponse.from_json(audiobook_data).results if not audiobook_list: # [] if page exceeds return - async def _get_id(alist: list[ABSLibraryItem] = audiobook_list) -> AsyncGenerator[str]: + async def _get_id( + alist: list[ABSLibraryItemMinifiedBook] = audiobook_list, + ) -> AsyncGenerator[str]: for entry in alist: yield entry.id_ @@ -309,11 +326,11 @@ class ABSClient: audiobook = await self.get_audiobook(id_) yield audiobook - async def get_audiobook(self, id_: str) -> ABSAudioBook: + async def get_audiobook(self, id_: str) -> ABSLibraryItemExpandedBook: """Get a single Audiobook by ID.""" # this endpoint gives more audiobook extra data audiobook = await self._get(f"items/{id_}?expanded=1") - return ABSAudioBook.from_json(audiobook) + return ABSLibraryItemExpandedBook.from_json(audiobook) async def get_playback_session_podcast( self, device_info: ABSDeviceInfo, podcast_id: str, episode_id: str diff --git a/music_assistant/providers/audiobookshelf/abs_schema.py b/music_assistant/providers/audiobookshelf/abs_schema.py index 687ed05e..c4a71a7f 100644 --- a/music_assistant/providers/audiobookshelf/abs_schema.py +++ b/music_assistant/providers/audiobookshelf/abs_schema.py @@ -1,6 +1,15 @@ -"""Schema definition of Audiobookshelf. +"""Schema definition of Audiobookshelf (ABS). https://api.audiobookshelf.org/ + +Some schema definitions have variants. Take book as example: +https://api.audiobookshelf.org/#book +Naming Scheme in this file: + - the standard definition has nothing added + - minified/ expanded: here, 2 additional variants + +Sometimes these variants remove or change attributes in such a way, that +it makes sense to define a base class for inheritance. """ from dataclasses import dataclass, field @@ -28,12 +37,12 @@ class BaseModel(DataClassJSONMixin): @dataclass class ABSAudioTrack(BaseModel): - """ABS audioTrack. + """ABS audioTrack. No variants. https://api.audiobookshelf.org/#audio-track """ - index: int + index: int | None start_offset: Annotated[float, Alias("startOffset")] = 0.0 duration: float = 0.0 title: str = "" @@ -43,167 +52,167 @@ class ABSAudioTrack(BaseModel): @dataclass -class ABSPodcastEpisodeExpanded(BaseModel): - """ABSPodcastEpisode. +class ABSBookChapter(BaseModel): + """ + ABSBookChapter. No variants. - https://api.audiobookshelf.org/#podcast-episode + https://api.audiobookshelf.org/#book-chapter """ - library_item_id: Annotated[str, Alias("libraryItemId")] - id_: Annotated[str, Alias("id")] - index: int | None - # audio_file: # not needed for mass application - published_at: Annotated[int | None, Alias("publishedAt")] # ms posix epoch - added_at: Annotated[int | None, Alias("addedAt")] # ms posix epoch - updated_at: Annotated[int | None, Alias("updatedAt")] # ms posix epoch - audio_track: Annotated[ABSAudioTrack, Alias("audioTrack")] - size: int # in bytes - season: str = "" - episode: str = "" - episode_type: Annotated[str, Alias("episodeType")] = "" - title: str = "" - subtitle: str = "" - description: str = "" - enclosure: str = "" - pub_date: Annotated[str, Alias("pubDate")] = "" - guid: str = "" - # chapters - duration: float = 0.0 + id_: Annotated[int, Alias("id")] + start: float + end: float + title: str @dataclass -class ABSPodcastMetaData(BaseModel): - """PodcastMetaData https://api.audiobookshelf.org/?shell#podcasts.""" +class ABSAudioBookmark(BaseModel): + """ABSAudioBookmark. No variants. - title: str | None - author: str | None - description: str | None - release_date: Annotated[str | None, Alias("releaseDate")] - genres: list[str] | None - feed_url: Annotated[str | None, Alias("feedUrl")] - image_url: Annotated[str | None, Alias("imageUrl")] - itunes_page_url: Annotated[str | None, Alias("itunesPageUrl")] - itunes_id: Annotated[int | None, Alias("itunesId")] - itunes_artist_id: Annotated[int | None, Alias("itunesArtistId")] - explicit: bool - language: str | None - type_: Annotated[str | None, Alias("type")] + https://api.audiobookshelf.org/#audio-bookmark + """ + + library_item_id: Annotated[str, Alias("libraryItemId")] + title: str + time: float # seconds + created_at: Annotated[int, Alias("createdAt")] # unix epoch ms @dataclass -class ABSPodcastMedia(BaseModel): - """ABSPodcastMedia.""" +class ABSUserPermissions(BaseModel): + """ABSUserPermissions. No variants. - metadata: ABSPodcastMetaData - cover_path: Annotated[str, Alias("coverPath")] - episodes: list[ABSPodcastEpisodeExpanded] - num_episodes: Annotated[int, Alias("numEpisodes")] = 0 + https://api.audiobookshelf.org/#user-permissions + """ + + download: bool + update: bool + delete: bool + upload: bool + access_all_libraries: Annotated[bool, Alias("accessAllLibraries")] + access_all_tags: Annotated[bool, Alias("accessAllTags")] + access_explicit_content: Annotated[bool, Alias("accessExplicitContent")] @dataclass -class ABSPodcast(BaseModel): - """ABSPodcast. +class ABSLibrary(BaseModel): + """ABSLibrary. No variants. - Depending on endpoint we get different results. This class does not - fully reflect https://api.audiobookshelf.org/#podcast. + https://api.audiobookshelf.org/#library + Only attributes we need """ id_: Annotated[str, Alias("id")] - media: ABSPodcastMedia + name: str + # folders + # displayOrder: Integer + # icon: String + media_type: Annotated[str, Alias("mediaType")] + provider: str + # settings + created_at: Annotated[int, Alias("createdAt")] + last_update: Annotated[int, Alias("lastUpdate")] @dataclass -class ABSAuthorMinified(BaseModel): - """ABSAuthor. +class ABSDeviceInfo(BaseModel): + """ABSDeviceInfo. No variants. - https://api.audiobookshelf.org/#author + https://api.audiobookshelf.org/#device-info-parameters + https://api.audiobookshelf.org/#device-info + https://github.com/advplyr/audiobookshelf/blob/master/server/objects/DeviceInfo.js#L3 """ - id_: Annotated[str, Alias("id")] - name: str + device_id: Annotated[str, Alias("deviceId")] = "" + client_name: Annotated[str, Alias("clientName")] = "" + client_version: Annotated[str, Alias("clientVersion")] = "" + manufacturer: str = "" + model: str = "" + # sdkVersion # meant for an Android client + + +### Author: https://api.audiobookshelf.org/#author @dataclass -class ABSSeriesSequence(BaseModel): - """Series Sequence. +class ABSAuthorMinified(BaseModel): + """ABSAuthorMinified. - https://api.audiobookshelf.org/#series + https://api.audiobookshelf.org/#author """ id_: Annotated[str, Alias("id")] name: str - sequence: str | None @dataclass -class ABSAudioBookMetaData(BaseModel): - """ABSAudioBookMetaData. - - https://api.audiobookshelf.org/#book-metadata - """ +class ABSAuthor(ABSAuthorMinified): + """ABSAuthor.""" - title: str - subtitle: str - authors: list[ABSAuthorMinified] - narrators: list[str] - series: list[ABSSeriesSequence] - genres: list[str] | None - published_year: Annotated[str | None, Alias("publishedYear")] - published_date: Annotated[str | None, Alias("publishedDate")] - publisher: str | None - description: str | None - isbn: str | None asin: str | None - language: str | None - explicit: bool + description: str | None + image_path: Annotated[str | None, Alias("imagePath")] + added_at: Annotated[int, Alias("addedAt")] # ms epoch + updated_at: Annotated[int, Alias("updatedAt")] # ms epoch @dataclass -class ABSAudioBookChapter(BaseModel): - """ - ABSAudioBookChapter. +class ABSAuthorExpanded(ABSAuthor): + """ABSAuthorExpanded.""" - https://api.audiobookshelf.org/#book-chapter - """ + num_books: Annotated[int, Alias("numBooks")] - id_: Annotated[int, Alias("id")] - start: float - end: float - title: str + +### Series: https://api.audiobookshelf.org/#series @dataclass -class ABSAudioBookMedia(BaseModel): - """ABSAudioBookMedia. +class _ABSSeriesBase(BaseModel): + """_ABSSeriesBase.""" - Helper class due to API endpoint used. - """ + id_: Annotated[str, Alias("id")] + name: str - metadata: ABSAudioBookMetaData - cover_path: Annotated[str, Alias("coverPath")] - chapters: list[ABSAudioBookChapter] - duration: float - tracks: list[ABSAudioTrack] + +@dataclass +class ABSSeries(_ABSSeriesBase): + """ABSSeries.""" + + description: str | None + added_at: Annotated[int, Alias("addedAt")] # ms epoch + updated_at: Annotated[int, Alias("updatedAt")] # ms epoch + + +@dataclass +class ABSSeriesNumBooks(_ABSSeriesBase): + """ABSSeriesNumBooks.""" + + name_ignore_prefix: Annotated[str, Alias("nameIgnorePrefix")] + library_item_ids: Annotated[list[str], Alias("libraryItemIds")] + num_books: Annotated[int, Alias("numBooks")] @dataclass -class ABSAudioBook(BaseModel): - """ABSAudioBook. +class ABSSeriesSequence(BaseModel): + """Series Sequence. - Depending on endpoint we get different results. This class does not - full reflect https://api.audiobookshelf.org/#book. + https://api.audiobookshelf.org/#series """ id_: Annotated[str, Alias("id")] - media: ABSAudioBookMedia + name: str + sequence: str | None + + +# another variant, ABSSeriesBooks is further down + + +### https://api.audiobookshelf.org/#media-progress @dataclass class ABSMediaProgress(BaseModel): - """ABSMediaProgress. - - https://api.audiobookshelf.org/#media-progress - """ + """ABSMediaProgress.""" id_: Annotated[str, Alias("id")] library_item_id: Annotated[str, Alias("libraryItemId")] @@ -218,27 +227,7 @@ class ABSMediaProgress(BaseModel): finished_at: Annotated[int | None, Alias("finishedAt")] # ms epoch -@dataclass -class ABSAudioBookmark(BaseModel): - """ABSAudioBookmark.""" - - library_item_id: Annotated[str, Alias("libraryItemId")] - title: str - time: float # seconds - created_at: Annotated[int, Alias("createdAt")] # unix epoch ms - - -@dataclass -class ABSUserPermissions(BaseModel): - """ABSUserPermissions.""" - - download: bool - update: bool - delete: bool - upload: bool - access_all_libraries: Annotated[bool, Alias("accessAllLibraries")] - access_all_tags: Annotated[bool, Alias("accessAllTags")] - access_explicit_content: Annotated[bool, Alias("accessExplicitContent")] +# two additional progress variants, 'with media' book and podcast, further down. @dataclass @@ -269,91 +258,7 @@ class ABSUser(BaseModel): # item_tags_accessible: Annotated[list[str], Alias("itemTagsAccessible")] -@dataclass -class ABSLoginResponse(BaseModel): - """ABSLoginResponse.""" - - user: ABSUser - - # this seems to be missing - # user_default_library_id: Annotated[str, Alias("defaultLibraryId")] - - -@dataclass -class ABSLibrary(BaseModel): - """ABSLibrary. - - Only attributes we need - """ - - id_: Annotated[str, Alias("id")] - name: str - # folders - # displayOrder: Integer - # icon: String - media_type: Annotated[str, Alias("mediaType")] - provider: str - # settings - created_at: Annotated[int, Alias("createdAt")] - last_update: Annotated[int, Alias("lastUpdate")] - - -@dataclass -class ABSLibrariesResponse(BaseModel): - """ABSLibrariesResponse.""" - - libraries: list[ABSLibrary] - - -@dataclass -class ABSLibraryItem(BaseModel): - """ABSLibraryItem.""" - - id_: Annotated[str, Alias("id")] - - -@dataclass -class ABSLibrariesItemsResponse(BaseModel): - """ABSLibrariesItemsResponse. - - https://api.audiobookshelf.org/#get-a-library-39-s-items - """ - - results: list[ABSLibraryItem] - - -# Schema to enable sessions: -@dataclass -class ABSDeviceInfo(BaseModel): - """ABSDeviceInfo. - - https://api.audiobookshelf.org/#device-info-parameters - https://api.audiobookshelf.org/#device-info - https://github.com/advplyr/audiobookshelf/blob/master/server/objects/DeviceInfo.js#L3 - """ - - device_id: Annotated[str, Alias("deviceId")] = "" - client_name: Annotated[str, Alias("clientName")] = "" - client_version: Annotated[str, Alias("clientVersion")] = "" - manufacturer: str = "" - model: str = "" - # sdkVersion # meant for an Android client - - -@dataclass -class ABSPlayRequest(BaseModel): - """ABSPlayRequest. - - https://api.audiobookshelf.org/#play-a-library-item-or-podcast-episode - """ - - device_info: Annotated[ABSDeviceInfo, Alias("deviceInfo")] - force_direct_play: Annotated[bool, Alias("forceDirectPlay")] = False - force_transcode: Annotated[bool, Alias("forceTranscode")] = False - supported_mime_types: Annotated[list[str], Alias("supportedMimeTypes")] = field( - default_factory=list - ) - media_player: Annotated[str, Alias("mediaPlayer")] = "unknown" +# two additional user variants do exist class ABSPlayMethod(Enum): @@ -365,12 +270,12 @@ class ABSPlayMethod(Enum): LOCAL = 3 +### https://api.audiobookshelf.org/#playback-session + + @dataclass class ABSPlaybackSession(BaseModel): - """ABSPlaybackSessionExpanded. - - https://api.audiobookshelf.org/#play-method - """ + """ABSPlaybackSession.""" id_: Annotated[str, Alias("id")] user_id: Annotated[str, Alias("userId")] @@ -401,10 +306,7 @@ class ABSPlaybackSession(BaseModel): @dataclass class ABSPlaybackSessionExpanded(ABSPlaybackSession): - """ABSPlaybackSessionExpanded. - - https://api.audiobookshelf.org/#play-method - """ + """ABSPlaybackSessionExpanded.""" audio_tracks: Annotated[list[ABSAudioTrack], Alias("audioTracks")] @@ -412,18 +314,373 @@ class ABSPlaybackSessionExpanded(ABSPlaybackSession): # libraryItem: +### https://api.audiobookshelf.org/#podcast-metadata + + @dataclass -class ABSSessionUpdate(BaseModel): - """ - ABSSessionUpdate. +class ABSPodcastMetadata(BaseModel): + """ABSPodcastMetadata.""" - Can be used as optional data to sync or closing request. - unit is seconds + title: str | None + author: str | None + description: str | None + release_date: Annotated[str | None, Alias("releaseDate")] + genres: list[str] | None + feed_url: Annotated[str | None, Alias("feedUrl")] + image_url: Annotated[str | None, Alias("imageUrl")] + itunes_page_url: Annotated[str | None, Alias("itunesPageUrl")] + itunes_id: Annotated[int | None, Alias("itunesId")] + itunes_artist_id: Annotated[int | None, Alias("itunesArtistId")] + explicit: bool + language: str | None + type_: Annotated[str | None, Alias("type")] + + +@dataclass +class ABSPodcastMetadataMinified(ABSPodcastMetadata): + """ABSPodcastMetadataMinified.""" + + title_ignore_prefix: Annotated[str, Alias("titleIgnorePrefix")] + + +ABSPodcastMetaDataExpanded = ABSPodcastMetadataMinified + +### https://api.audiobookshelf.org/#podcast-episode + + +@dataclass +class ABSPodcastEpisode(BaseModel): + """ABSPodcastEpisode.""" + + library_item_id: Annotated[str, Alias("libraryItemId")] + id_: Annotated[str, Alias("id")] + index: int | None + # audio_file: # not needed for mass application + published_at: Annotated[int | None, Alias("publishedAt")] # ms posix epoch + added_at: Annotated[int | None, Alias("addedAt")] # ms posix epoch + updated_at: Annotated[int | None, Alias("updatedAt")] # ms posix epoch + season: str = "" + episode: str = "" + episode_type: Annotated[str, Alias("episodeType")] = "" + title: str = "" + subtitle: str = "" + description: str = "" + enclosure: str = "" + pub_date: Annotated[str, Alias("pubDate")] = "" + guid: str = "" + # chapters + + +@dataclass +class ABSPodcastEpisodeExpanded(BaseModel): + """ABSPodcastEpisode. + + https://api.audiobookshelf.org/#podcast-episode """ - current_time: Annotated[float, Alias("currentTime")] - time_listened: Annotated[float, Alias("timeListened")] + library_item_id: Annotated[str, Alias("libraryItemId")] + id_: Annotated[str, Alias("id")] + index: int | None + # audio_file: # not needed for mass application + published_at: Annotated[int | None, Alias("publishedAt")] # ms posix epoch + added_at: Annotated[int | None, Alias("addedAt")] # ms posix epoch + updated_at: Annotated[int | None, Alias("updatedAt")] # ms posix epoch + audio_track: Annotated[ABSAudioTrack, Alias("audioTrack")] + size: int # in bytes + season: str = "" + episode: str = "" + episode_type: Annotated[str, Alias("episodeType")] = "" + title: str = "" + subtitle: str = "" + description: str = "" + enclosure: str = "" + pub_date: Annotated[str, Alias("pubDate")] = "" + guid: str = "" + # chapters + duration: float = 0.0 + + +@dataclass +class _ABSPodcastBase(BaseModel): + """_ABSPodcastBase.""" + + cover_path: Annotated[str, Alias("coverPath")] + + +### https://api.audiobookshelf.org/#podcast + + +@dataclass +class ABSPodcast(_ABSPodcastBase): + """ABSPodcast.""" + + metadata: ABSPodcastMetadata + library_item_id: Annotated[str, Alias("libraryItemId")] + tags: list[str] + episodes: list[ABSPodcastEpisode] + + +@dataclass +class ABSPodcastMinified(_ABSPodcastBase): + """ABSPodcastMinified.""" + + metadata: ABSPodcastMetadataMinified + size: int # bytes + num_episodes: Annotated[int, Alias("numEpisodes")] = 0 + + +@dataclass +class ABSPodcastExpanded(_ABSPodcastBase): + """ABSPodcastEpisodeExpanded.""" + + size: int # bytes + metadata: ABSPodcastMetaDataExpanded + episodes: list[ABSPodcastEpisodeExpanded] + + +### https://api.audiobookshelf.org/#book-metadata + + +@dataclass +class _ABSBookMetadataBase(BaseModel): + """_ABSBookMetadataBase.""" + + title: str + subtitle: str + genres: list[str] | None + published_year: Annotated[str | None, Alias("publishedYear")] + published_date: Annotated[str | None, Alias("publishedDate")] + publisher: str | None + description: str | None + isbn: str | None + asin: str | None + language: str | None + explicit: bool + + +@dataclass +class ABSBookMetadata(_ABSBookMetadataBase): + """ABSBookMetadata.""" + + authors: list[ABSAuthorMinified] + narrators: list[str] + series: list[ABSSeriesSequence] + + +@dataclass +class ABSBookMetadataMinified(_ABSBookMetadataBase): + """ABSBookMetadataMinified.""" + + # these are normally there + title_ignore_prefix: Annotated[str, Alias("titleIgnorePrefix")] + author_name: Annotated[str, Alias("authorName")] + author_name_lf: Annotated[str, Alias("authorNameLF")] + narrator_name: Annotated[str, Alias("narratorName")] + series_name: Annotated[str, Alias("seriesName")] + + +@dataclass +class ABSBookMetadataExpanded(ABSBookMetadata, ABSBookMetadataMinified): + """ABSAudioBookMetaDataExpanded.""" + + +### https://api.audiobookshelf.org/#book + + +@dataclass +class _ABSBookBase(BaseModel): + """_ABSBookBase.""" + + tags: list[str] + cover_path: Annotated[str | None, Alias("coverPath")] + + +@dataclass +class ABSBook(_ABSBookBase): + """ABSBook.""" + + library_item_id: Annotated[str, Alias("libraryItemId")] + metadata: ABSBookMetadata + # audioFiles + chapters: list[ABSBookChapter] + # ebookFile + + +@dataclass +class ABSBookMinified(_ABSBookBase): + """ABSBookBase.""" + + metadata: ABSBookMetadataMinified + num_tracks: Annotated[int, Alias("numTracks")] + num_audiofiles: Annotated[int, Alias("numAudioFiles")] + num_chapters: Annotated[int, Alias("numChapters")] + duration: float # in s + size: int # in bytes + # ebookFormat + + +@dataclass +class ABSBookExpanded(_ABSBookBase): + """ABSBookExpanded.""" + + library_item_id: Annotated[str, Alias("libraryItemId")] + metadata: ABSBookMetadataExpanded + chapters: list[ABSBookChapter] duration: float + size: int # bytes + tracks: list[ABSAudioTrack] + + +### https://api.audiobookshelf.org/#library-item + + +@dataclass +class _ABSLibraryItemBase(BaseModel): + """_ABSLibraryItemBase.""" + + id_: Annotated[str, Alias("id")] + ino: str + library_id: Annotated[str, Alias("libraryId")] + folder_id: Annotated[str, Alias("folderId")] + path: str + relative_path: Annotated[str, Alias("relPath")] + is_file: Annotated[bool, Alias("isFile")] + last_modified_ms: Annotated[int, Alias("mtimeMs")] # epoch + last_changed_ms: Annotated[int, Alias("ctimeMs")] # epoch + birthtime_ms: Annotated[int, Alias("birthtimeMs")] # epoch + added_at: Annotated[int, Alias("addedAt")] # ms epoch + updated_at: Annotated[int, Alias("updatedAt")] # ms epoch + is_missing: Annotated[bool, Alias("isMissing")] + is_invalid: Annotated[bool, Alias("isInvalid")] + media_type: Annotated[str, Alias("mediaType")] + + +@dataclass +class _ABSLibraryItem(_ABSLibraryItemBase): + """ABSLibraryItem.""" + + last_scan: Annotated[int | None, Alias("lastScan")] # ms epoch + scan_version: Annotated[str | None, Alias("scanVersion")] + # libraryFiles + + +@dataclass +class ABSLibraryItemBook(_ABSLibraryItem): + """ABSLibraryItemBook.""" + + media: ABSBook + + +@dataclass +class ABSLibraryItemBookSeries(ABSLibraryItemBook): + """ABSLibraryItemNormalBookSeries. + + Special class, when having the scheme of SeriesBooks, see + https://api.audiobookshelf.org/#series, it gets an extra + sequence key. + """ + + sequence: int + + +@dataclass +class ABSLibraryItemPodcast(_ABSLibraryItem): + """ABSLibraryItemPodcast.""" + + media: ABSPodcast + + +@dataclass +class _ABSLibraryItemMinified(_ABSLibraryItemBase): + """ABSLibraryItemMinified.""" + + num_files: Annotated[int, Alias("numFiles")] + size: int # bytes + + +@dataclass +class ABSLibraryItemMinifiedBook(_ABSLibraryItemMinified): + """ABSLibraryItemMinifiedBook.""" + + media: ABSBookMinified + + +@dataclass +class ABSLibraryItemMinifiedPodcast(_ABSLibraryItemMinified): + """ABSLibraryItemMinifiedBook.""" + + media: ABSPodcastMinified + + +@dataclass +class _ABSLibraryItemExpanded(_ABSLibraryItemBase): + """ABSLibraryItemExpanded.""" + + size: int # bytes + + +@dataclass +class ABSLibraryItemExpandedBook(_ABSLibraryItemExpanded): + """ABSLibraryItemExpanded.""" + + media: ABSBookExpanded + + +@dataclass +class ABSLibraryItemExpandedPodcast(_ABSLibraryItemExpanded): + """ABSLibraryItemExpanded.""" + + media: ABSPodcastExpanded + + +# extra classes down here so they can make proper references + + +@dataclass +class ABSSeriesBooks(_ABSSeriesBase): + """ABSSeriesBooks.""" + + added_at: Annotated[int, Alias("addedAt")] # ms epoch + name_ignore_prefix: Annotated[str, Alias("nameIgnorePrefix")] + name_ignore_prefix_sort: Annotated[str, Alias("nameIgnorePrefixSort")] + type_: Annotated[str, Alias("type")] + books: list[ABSLibraryItemBookSeries] + total_duration: Annotated[float, Alias("totalDuration")] # s + + +@dataclass +class ABSMediaProgressWithMediaBook(ABSMediaProgress): + """ABSMediaProgressWithMediaBook.""" + + media: ABSBookExpanded + + +@dataclass +class ABSMediaProgressWithMediaPodcast(ABSMediaProgress): + """ABSMediaProgressWithMediaBook.""" + + media: ABSPodcastExpanded + episode: ABSPodcastEpisode + + +### Response to API Requests + + +@dataclass +class ABSLoginResponse(BaseModel): + """ABSLoginResponse.""" + + user: ABSUser + + # this seems to be missing + # user_default_library_id: Annotated[str, Alias("defaultLibraryId")] + + +@dataclass +class ABSLibrariesResponse(BaseModel): + """ABSLibrariesResponse.""" + + libraries: list[ABSLibrary] @dataclass @@ -434,3 +691,59 @@ class ABSSessionsResponse(BaseModel): num_pages: Annotated[int, Alias("numPages")] items_per_page: Annotated[int, Alias("itemsPerPage")] sessions: list[ABSPlaybackSession] + + +@dataclass +class ABSLibrariesItemsMinifiedBookResponse(BaseModel): + """ABSLibrariesItemsResponse. + + https://api.audiobookshelf.org/#get-a-library-39-s-items + No matter what options I append to the request, I always end up with + minified items. Maybe a bug in abs. If that would be fixed, there is + potential for reduced in API calls. + """ + + results: list[ABSLibraryItemMinifiedBook] + + +@dataclass +class ABSLibrariesItemsMinifiedPodcastResponse(BaseModel): + """ABSLibrariesItemsResponse. + + see above. + """ + + results: list[ABSLibraryItemMinifiedPodcast] + + +### Requests to API we can make + + +@dataclass +class ABSPlayRequest(BaseModel): + """ABSPlayRequest. + + https://api.audiobookshelf.org/#play-a-library-item-or-podcast-episode + """ + + device_info: Annotated[ABSDeviceInfo, Alias("deviceInfo")] + force_direct_play: Annotated[bool, Alias("forceDirectPlay")] = False + force_transcode: Annotated[bool, Alias("forceTranscode")] = False + supported_mime_types: Annotated[list[str], Alias("supportedMimeTypes")] = field( + default_factory=list + ) + media_player: Annotated[str, Alias("mediaPlayer")] = "unknown" + + +@dataclass +class ABSSessionUpdate(BaseModel): + """ + ABSSessionUpdate. + + Can be used as optional data to sync or closing request. + unit is seconds + """ + + current_time: Annotated[float, Alias("currentTime")] + time_listened: Annotated[float, Alias("timeListened")] + duration: float -- 2.34.1