aioaudiobookshelf 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of aioaudiobookshelf might be problematic. Click here for more details.

Files changed (45) hide show
  1. aioaudiobookshelf/__init__.py +72 -0
  2. aioaudiobookshelf/client/__init__.py +189 -0
  3. aioaudiobookshelf/client/_base.py +110 -0
  4. aioaudiobookshelf/client/authors.py +34 -0
  5. aioaudiobookshelf/client/collections_.py +28 -0
  6. aioaudiobookshelf/client/items.py +108 -0
  7. aioaudiobookshelf/client/libraries.py +173 -0
  8. aioaudiobookshelf/client/me.py +103 -0
  9. aioaudiobookshelf/client/playlists.py +29 -0
  10. aioaudiobookshelf/client/podcasts.py +22 -0
  11. aioaudiobookshelf/client/series.py +27 -0
  12. aioaudiobookshelf/client/session.py +35 -0
  13. aioaudiobookshelf/exceptions.py +13 -0
  14. aioaudiobookshelf/helpers.py +56 -0
  15. aioaudiobookshelf/schema/__init__.py +14 -0
  16. aioaudiobookshelf/schema/audio.py +71 -0
  17. aioaudiobookshelf/schema/author.py +46 -0
  18. aioaudiobookshelf/schema/book.py +125 -0
  19. aioaudiobookshelf/schema/calls_authors.py +33 -0
  20. aioaudiobookshelf/schema/calls_collections.py +14 -0
  21. aioaudiobookshelf/schema/calls_items.py +53 -0
  22. aioaudiobookshelf/schema/calls_library.py +103 -0
  23. aioaudiobookshelf/schema/calls_login.py +28 -0
  24. aioaudiobookshelf/schema/calls_me.py +28 -0
  25. aioaudiobookshelf/schema/calls_playlists.py +14 -0
  26. aioaudiobookshelf/schema/calls_series.py +25 -0
  27. aioaudiobookshelf/schema/calls_session.py +17 -0
  28. aioaudiobookshelf/schema/collection.py +36 -0
  29. aioaudiobookshelf/schema/events_socket.py +46 -0
  30. aioaudiobookshelf/schema/file.py +22 -0
  31. aioaudiobookshelf/schema/folder.py +18 -0
  32. aioaudiobookshelf/schema/library.py +221 -0
  33. aioaudiobookshelf/schema/media_progress.py +42 -0
  34. aioaudiobookshelf/schema/playlist.py +74 -0
  35. aioaudiobookshelf/schema/podcast.py +129 -0
  36. aioaudiobookshelf/schema/series.py +45 -0
  37. aioaudiobookshelf/schema/series_books.py +34 -0
  38. aioaudiobookshelf/schema/server.py +76 -0
  39. aioaudiobookshelf/schema/session.py +78 -0
  40. aioaudiobookshelf/schema/user.py +81 -0
  41. aioaudiobookshelf-0.1.0.dist-info/LICENSE +201 -0
  42. aioaudiobookshelf-0.1.0.dist-info/METADATA +32 -0
  43. aioaudiobookshelf-0.1.0.dist-info/RECORD +45 -0
  44. aioaudiobookshelf-0.1.0.dist-info/WHEEL +5 -0
  45. aioaudiobookshelf-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,173 @@
1
+ """Calls to /api/libraries."""
2
+
3
+ from collections.abc import AsyncGenerator
4
+ from typing import TypeVar
5
+
6
+ from mashumaro.mixins.json import DataClassJSONMixin
7
+
8
+ from aioaudiobookshelf.client._base import BaseClient
9
+ from aioaudiobookshelf.schema.author import AuthorExpanded, Narrator
10
+ from aioaudiobookshelf.schema.calls_library import (
11
+ AllLibrariesResponse,
12
+ LibraryAuthorsResponse,
13
+ LibraryCollectionsMinifiedResponse,
14
+ LibraryItemsMinifiedResponse,
15
+ LibraryNarratorsResponse,
16
+ LibraryPlaylistsResponse,
17
+ LibrarySeriesMinifiedResponse,
18
+ LibraryWithFilterDataResponse,
19
+ )
20
+ from aioaudiobookshelf.schema.library import Library, LibraryFilterData
21
+
22
+ ResponseMinified = TypeVar("ResponseMinified", bound=DataClassJSONMixin)
23
+ ResponseNormal = TypeVar("ResponseNormal", bound=DataClassJSONMixin)
24
+
25
+
26
+ class LibrariesClient(BaseClient):
27
+ """LibrariesClient."""
28
+
29
+ # create library
30
+
31
+ async def get_all_libraries(self) -> list[Library]:
32
+ """Get all user accessible libraries."""
33
+ response = await self._get("/api/libraries")
34
+ return AllLibrariesResponse.from_json(response).libraries
35
+
36
+ async def get_library(self, *, library_id: str) -> Library:
37
+ """Get single library."""
38
+ response = await self._get(f"/api/libraries/{library_id}")
39
+ return Library.from_json(response)
40
+
41
+ async def get_library_with_filterdata(
42
+ self, *, library_id: str
43
+ ) -> LibraryWithFilterDataResponse:
44
+ """Get single library including filterdata."""
45
+ response = await self._get(f"/api/libraries/{library_id}?include=filterdata")
46
+ return LibraryWithFilterDataResponse.from_json(response)
47
+
48
+ # update library
49
+ # delete library
50
+
51
+ async def _get_library_with_pagination(
52
+ self,
53
+ *,
54
+ endpoint: str,
55
+ minified: bool = False,
56
+ response_cls_minified: type[ResponseMinified],
57
+ response_cls: type[ResponseNormal],
58
+ filter_str: str | None = None,
59
+ ) -> AsyncGenerator[ResponseMinified | ResponseNormal]:
60
+ page_cnt = 0
61
+ params: dict[str, int | str] = {
62
+ "minified": int(minified),
63
+ "limit": self.session_config.pagination_items_per_page,
64
+ }
65
+ if filter_str is not None:
66
+ params["filter"] = filter_str
67
+ while True:
68
+ params["page"] = page_cnt
69
+ response = await self._get(endpoint, params)
70
+ page_cnt += 1
71
+ if minified:
72
+ yield response_cls_minified.from_json(response)
73
+ else:
74
+ yield response_cls.from_json(response)
75
+
76
+ async def get_library_items(
77
+ self, *, library_id: str, filter_str: str | None = None
78
+ ) -> AsyncGenerator[LibraryItemsMinifiedResponse]:
79
+ """Get library items.
80
+
81
+ Returns only minified items at this point.
82
+ """
83
+ # only minified response is supported at API
84
+ minified: bool = True
85
+ endpoint = f"/api/libraries/{library_id}/items"
86
+ async for result in self._get_library_with_pagination(
87
+ endpoint=endpoint,
88
+ minified=minified,
89
+ response_cls_minified=LibraryItemsMinifiedResponse,
90
+ response_cls=LibraryItemsMinifiedResponse,
91
+ filter_str=filter_str,
92
+ ):
93
+ yield result
94
+
95
+ # remove item with issues
96
+ # get lib podcast episode downloads
97
+
98
+ async def get_library_series(
99
+ self, *, library_id: str
100
+ ) -> AsyncGenerator[LibrarySeriesMinifiedResponse]:
101
+ """Get series in that library.
102
+
103
+ Returns only minified items at this point.
104
+ """
105
+ # only minified response is supported
106
+ minified: bool = True
107
+ endpoint = f"/api/libraries/{library_id}/series"
108
+ async for result in self._get_library_with_pagination(
109
+ endpoint=endpoint,
110
+ minified=minified,
111
+ response_cls=LibrarySeriesMinifiedResponse,
112
+ response_cls_minified=LibrarySeriesMinifiedResponse,
113
+ ):
114
+ yield result
115
+
116
+ async def get_library_collections(
117
+ self, *, library_id: str
118
+ ) -> AsyncGenerator[LibraryCollectionsMinifiedResponse]:
119
+ """Get collections in that library.
120
+
121
+ Returns only minified items at this point.
122
+ """
123
+ # only minified response is supported
124
+ minified: bool = True
125
+ endpoint = f"/api/libraries/{library_id}/collections"
126
+ async for result in self._get_library_with_pagination(
127
+ endpoint=endpoint,
128
+ minified=minified,
129
+ response_cls=LibraryCollectionsMinifiedResponse,
130
+ response_cls_minified=LibraryCollectionsMinifiedResponse,
131
+ ):
132
+ yield result
133
+
134
+ async def get_library_playlists(
135
+ self, *, library_id: str
136
+ ) -> AsyncGenerator[LibraryPlaylistsResponse]:
137
+ """Get collections in that library.
138
+
139
+ Returns only minified items at this point.
140
+ """
141
+ endpoint = f"/api/libraries/{library_id}/playlists"
142
+ async for result in self._get_library_with_pagination(
143
+ endpoint=endpoint,
144
+ minified=False, # there is no minified version
145
+ response_cls=LibraryPlaylistsResponse,
146
+ response_cls_minified=LibraryPlaylistsResponse,
147
+ ):
148
+ yield result
149
+
150
+ # get library's personalized view
151
+
152
+ async def get_library_filterdata(self, *, library_id: str) -> LibraryFilterData:
153
+ """Get filterdata of library."""
154
+ response = await self._get(endpoint=f"/api/libraries/{library_id}/filterdata")
155
+ return LibraryFilterData.from_json(response)
156
+
157
+ # search library
158
+ # get lib stats
159
+
160
+ async def get_library_authors(self, *, library_id: str) -> list[AuthorExpanded]:
161
+ """Get authors of library."""
162
+ response = await self._get(endpoint=f"/api/libraries/{library_id}/authors")
163
+ return LibraryAuthorsResponse.from_json(response).authors
164
+
165
+ async def get_library_narrators(self, *, library_id: str) -> list[Narrator]:
166
+ """Get narrators of a library."""
167
+ response = await self._get(endpoint=f"/api/libraries/{library_id}/narrators")
168
+ return LibraryNarratorsResponse.from_json(response).narrators
169
+
170
+ # match lib items
171
+ # scan lib folders
172
+ # library recent episodes
173
+ # reorder list
@@ -0,0 +1,103 @@
1
+ """Calls to /api/me."""
2
+
3
+ from collections.abc import AsyncGenerator
4
+
5
+ from aioaudiobookshelf.client._base import BaseClient
6
+ from aioaudiobookshelf.schema.calls_me import (
7
+ MeListeningSessionsParameters,
8
+ MeListeningSessionsResponse,
9
+ )
10
+ from aioaudiobookshelf.schema.media_progress import MediaProgress
11
+ from aioaudiobookshelf.schema.user import User
12
+
13
+
14
+ class MeClient(BaseClient):
15
+ """MeClient."""
16
+
17
+ async def get_my_user(self) -> User:
18
+ """Get this client's user."""
19
+ data = await self._get("/api/me")
20
+ return User.from_json(data)
21
+
22
+ async def get_my_listening_sessions(self) -> AsyncGenerator[MeListeningSessionsResponse]:
23
+ """Get this user's listening sessions."""
24
+ raise NotImplementedError("PodcastMetadata not fully returned.")
25
+ page_cnt = 0
26
+ params = MeListeningSessionsParameters(
27
+ items_per_page=self.session_config.pagination_items_per_page, page=page_cnt
28
+ )
29
+ while True:
30
+ params.page = page_cnt
31
+ response = await self._get("/api/me/listening-sessions", params.to_dict())
32
+ page_cnt += 1
33
+ yield MeListeningSessionsResponse.from_json(response)
34
+
35
+ # listening stats
36
+ # remove item from continue listening
37
+
38
+ async def get_my_media_progress(
39
+ self, *, item_id: str, episode_id: str | None = None
40
+ ) -> MediaProgress | None:
41
+ """Get a MediaProgress, returns None if none found."""
42
+ endpoint = f"/api/me/progress/{item_id}"
43
+ if episode_id is not None:
44
+ endpoint += f"/{episode_id}"
45
+ response = await self._get(endpoint=endpoint)
46
+ if not response:
47
+ return None
48
+ return MediaProgress.from_json(response)
49
+
50
+ # batch create/ update media progress
51
+
52
+ async def update_my_media_progress(
53
+ self,
54
+ *,
55
+ item_id: str,
56
+ episode_id: str | None = None,
57
+ duration_seconds: float,
58
+ progress_seconds: float,
59
+ is_finished: bool,
60
+ ) -> None:
61
+ """Update progress of media item.
62
+
63
+ 0 <= progress_percent <= 1
64
+
65
+ Notes:
66
+ - progress in abs is percentage
67
+ - multiple parameters in one call don't work in all combinations
68
+ - currentTime is current position in s
69
+ - currentTime works only if duration is sent as well, but then don't
70
+ send progress at the same time.
71
+ """
72
+ logger_item = "audiobook" if not episode_id else "podcast"
73
+ endpoint = f"/api/me/progress/{item_id}"
74
+ if episode_id is not None:
75
+ endpoint += f"/{episode_id}"
76
+ await self._patch(
77
+ endpoint,
78
+ data={"isFinished": is_finished},
79
+ )
80
+ if is_finished:
81
+ self.logger.debug("Marked %s, id %s finished.", logger_item, item_id)
82
+ return
83
+ percentage = progress_seconds / duration_seconds
84
+ await self._patch(
85
+ endpoint,
86
+ data={"progress": percentage},
87
+ )
88
+ await self._patch(
89
+ endpoint,
90
+ data={"duration": duration_seconds, "currentTime": progress_seconds},
91
+ )
92
+ self.logger.debug(
93
+ "Updated progress of %s, id %s to %.2f%%.", logger_item, item_id, percentage * 100
94
+ )
95
+
96
+ async def remove_my_media_progress(self, *, media_progress_id: str) -> None:
97
+ """Remove a single media progress."""
98
+ await self._delete(f"/api/me/progress/{media_progress_id}")
99
+
100
+ # create, update, remove bookmark
101
+ # change password
102
+ # get lib items in progress
103
+ # remove series from continue listening
@@ -0,0 +1,29 @@
1
+ """Calls to /api/playlists."""
2
+
3
+ from aioaudiobookshelf.schema.calls_playlists import AllPlaylistsResponse
4
+ from aioaudiobookshelf.schema.playlist import PlaylistExpanded
5
+
6
+ from ._base import BaseClient
7
+
8
+
9
+ class PlaylistsClient(BaseClient):
10
+ """PlaylistsClient."""
11
+
12
+ # create playlist
13
+
14
+ async def get_all_playlists(self) -> list[PlaylistExpanded]:
15
+ """Get all playlists accessible to user."""
16
+ data = await self._get(endpoint="/api/playlists")
17
+ return AllPlaylistsResponse.from_json(data).playlists
18
+
19
+ async def get_playlist(self, *, playlist_id: str) -> PlaylistExpanded:
20
+ """Get a playlist."""
21
+ data = await self._get(endpoint=f"/api/playlists/{playlist_id}")
22
+ return PlaylistExpanded.from_json(data)
23
+
24
+ # update
25
+ # delete
26
+ # add item
27
+ # remove item
28
+ # batch add + remove
29
+ # create playlist from collection
@@ -0,0 +1,22 @@
1
+ """Calls to /api/podcasts."""
2
+
3
+ from aioaudiobookshelf.client._base import BaseClient
4
+ from aioaudiobookshelf.schema.podcast import PodcastEpisode
5
+
6
+
7
+ class PodcastsClient(BaseClient):
8
+ """PodcastClient."""
9
+
10
+ # create podcast
11
+ # get podcast feed
12
+ # feed from opml
13
+ # check for new episodes
14
+ # get podcast episode downloads
15
+ # search podcast feed
16
+ # download podcast episodes
17
+ # match podcast episode
18
+
19
+ async def get_podcast_episode(self, *, podcast_id: str, episode_id: str) -> PodcastEpisode:
20
+ """Get podcast episode."""
21
+ data = await self._get(f"/api/podcasts/{podcast_id}/episode/{episode_id}")
22
+ return PodcastEpisode.from_json(data)
@@ -0,0 +1,27 @@
1
+ """Calls to /api/series."""
2
+
3
+ from aioaudiobookshelf.client._base import BaseClient
4
+ from aioaudiobookshelf.schema.calls_series import SeriesWithProgress
5
+ from aioaudiobookshelf.schema.series import Series
6
+
7
+
8
+ class SeriesClient(BaseClient):
9
+ """SeriesClient."""
10
+
11
+ async def get_series(
12
+ self, *, series_id: str, include_progress: bool = False
13
+ ) -> Series | SeriesWithProgress:
14
+ """Get an author.
15
+
16
+ Include series always includes items.
17
+ """
18
+ response_cls: type[Series | SeriesWithProgress] = Series
19
+ endpoint = f"/api/series/{series_id}"
20
+ if include_progress:
21
+ endpoint += "?include=progress"
22
+ response_cls = SeriesWithProgress
23
+
24
+ response = await self._get(endpoint)
25
+ return response_cls.from_json(response)
26
+
27
+ # update series
@@ -0,0 +1,35 @@
1
+ """Calls to /api/session."""
2
+
3
+ from aioaudiobookshelf.client._base import BaseClient
4
+ from aioaudiobookshelf.schema.calls_session import CloseOpenSessionsParameters
5
+ from aioaudiobookshelf.schema.session import PlaybackSessionExpanded
6
+
7
+
8
+ class SessionClient(BaseClient):
9
+ """SessionClient."""
10
+
11
+ # get_all_session # admin
12
+ # delete session
13
+ # sync local session(s)
14
+
15
+ async def get_open_session(self, *, session_id: str) -> PlaybackSessionExpanded:
16
+ """Get open session."""
17
+ response = await self._get(f"/api/session/{session_id}")
18
+ psession = PlaybackSessionExpanded.from_json(response)
19
+ self.logger.debug(
20
+ "Got playback session %s for %s named %s.",
21
+ psession.id_,
22
+ psession.media_type,
23
+ psession.display_title,
24
+ )
25
+ return psession
26
+
27
+ # sync open session
28
+
29
+ async def close_open_session(
30
+ self, *, session_id: str, parameters: CloseOpenSessionsParameters | None = None
31
+ ) -> None:
32
+ """Close open session."""
33
+ _parameters = {} if parameters is None else parameters.to_dict()
34
+ self.logger.debug("Closing playback session %s.", session_id)
35
+ await self._post(f"/api/session/{session_id}/close", data=_parameters)
@@ -0,0 +1,13 @@
1
+ """Exceptions for aioaudiobookshelf."""
2
+
3
+
4
+ class BadUserError(Exception):
5
+ """Raised if this user is not suitable for the client."""
6
+
7
+
8
+ class LoginError(Exception):
9
+ """Exception raised if login failed."""
10
+
11
+
12
+ class ApiError(Exception):
13
+ """Exception raised if call to api failed."""
@@ -0,0 +1,56 @@
1
+ """Helpers for aioaudiobookshelf."""
2
+
3
+ import base64
4
+ import urllib.parse
5
+ from enum import StrEnum
6
+
7
+
8
+ class FilterGroup(StrEnum):
9
+ """FilterGroup."""
10
+
11
+ GENRES = "genres"
12
+ TAGS = "tags"
13
+ SERIES = "series"
14
+ AUTHORS = "authors"
15
+ PROGRESS = "progress"
16
+ NARRATORS = "narrators"
17
+ MISSING = "missing"
18
+ LANGUAGES = "languages"
19
+ TRACKS = "tracks"
20
+
21
+
22
+ class FilterProgressType(StrEnum):
23
+ """FilterProgressType."""
24
+
25
+ FINISHED = "finished"
26
+ NOTSTARTED = "not-started"
27
+ NOTFINISHED = "not-finished"
28
+ INPROGRESS = "in-progress"
29
+
30
+
31
+ def get_library_filter_string(
32
+ *, filter_group: FilterGroup, filter_value: str | FilterProgressType
33
+ ) -> str:
34
+ """Obtain a string usable as filter_str.
35
+
36
+ Currently only narrators, genre, tags, languages and progress.
37
+ """
38
+ if filter_group in [
39
+ FilterGroup.NARRATORS,
40
+ FilterGroup.GENRES,
41
+ FilterGroup.TAGS,
42
+ FilterGroup.LANGUAGES,
43
+ ]:
44
+ _encoded = urllib.parse.quote(base64.b64encode(filter_value.encode()))
45
+ return f"{filter_group.value}.{_encoded}"
46
+
47
+ if filter_group == FilterGroup.PROGRESS:
48
+ if filter_value not in FilterProgressType:
49
+ raise RuntimeError("Filter value not acceptable for progress.")
50
+ filter_value = (
51
+ filter_value.value if isinstance(filter_value, FilterProgressType) else filter_value
52
+ )
53
+ _encoded = urllib.parse.quote(base64.b64encode(filter_value.encode()))
54
+ return f"{filter_group.value}.{_encoded}"
55
+
56
+ raise NotImplementedError(f"The {filter_group=} is not yet implemented.")
@@ -0,0 +1,14 @@
1
+ """Schema for Audiobookshelf (abs)."""
2
+
3
+ from mashumaro.config import BaseConfig
4
+ from mashumaro.mixins.json import DataClassJSONMixin
5
+
6
+
7
+ class _BaseModel(DataClassJSONMixin):
8
+ """Model shared between schema definitions."""
9
+
10
+ class Config(BaseConfig):
11
+ """Base configuration."""
12
+
13
+ forbid_extra_keys = False
14
+ serialize_by_alias = True
@@ -0,0 +1,71 @@
1
+ """Schema for audio."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Annotated
5
+
6
+ from mashumaro.types import Alias
7
+
8
+ from . import _BaseModel
9
+ from .file import FileMetadata
10
+
11
+
12
+ @dataclass(kw_only=True)
13
+ class AudioBookmark(_BaseModel):
14
+ """AudioBookmark. No variants.
15
+
16
+ https://api.audiobookshelf.org/#audio-bookmark
17
+ """
18
+
19
+ library_item_id: Annotated[str, Alias("libraryItemId")]
20
+ title: str
21
+ time: int # seconds
22
+ created_at: Annotated[int, Alias("createdAt")] # unix epoch ms
23
+
24
+
25
+ @dataclass(kw_only=True)
26
+ class AudioTrack(_BaseModel):
27
+ """ABS audioTrack. No variants.
28
+
29
+ https://api.audiobookshelf.org/#audio-track
30
+ """
31
+
32
+ index: int | None
33
+ start_offset: Annotated[float, Alias("startOffset")]
34
+ duration: float
35
+ title: str
36
+ content_url: Annotated[str, Alias("contentUrl")]
37
+ metadata: FileMetadata | None
38
+ # is missing if part of library...
39
+ mime_type: str | None = None
40
+
41
+
42
+ @dataclass(kw_only=True)
43
+ class AudioFile(_BaseModel):
44
+ """Audiofile."""
45
+
46
+ index: int
47
+ ino: str
48
+ metadata: FileMetadata
49
+ added_at: Annotated[int, Alias("addedAt")]
50
+ updated_at: Annotated[int, Alias("updatedAt")]
51
+ track_num_from_meta: Annotated[int | None, Alias("trackNumFromMeta")] = None
52
+ disc_num_from_meta: Annotated[int | None, Alias("discNumFromMeta")] = None
53
+ track_num_from_filename: Annotated[int | None, Alias("trackNumFromFilename")] = None
54
+ disc_num_from_filename: Annotated[int | None, Alias("discNumFromFilename")] = None
55
+ manually_verified: Annotated[bool, Alias("manuallyVerified")]
56
+ exclude: bool
57
+ error: str | None = None
58
+ format: str
59
+ duration: float
60
+ bit_rate: Annotated[int, Alias("bitRate")]
61
+ language: str | None = None
62
+ codec: str
63
+ time_base: Annotated[str, Alias("timeBase")]
64
+ channels: int
65
+ channel_layout: Annotated[str, Alias("channelLayout")]
66
+ embedded_cover_art: Annotated[str | None, Alias("embeddedCoverArt")] = None
67
+ mime_type: Annotated[str, Alias("mimeType")]
68
+ # if part of a book
69
+ # chapters: list[BookChapter]
70
+ # id3 tags:
71
+ # meta_tags: AudioMetaTags
@@ -0,0 +1,46 @@
1
+ """Schema for Author."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Annotated
5
+
6
+ from mashumaro.types import Alias
7
+
8
+ from . import _BaseModel
9
+
10
+
11
+ @dataclass(kw_only=True)
12
+ class AuthorMinified(_BaseModel):
13
+ """AuthorMinified.
14
+
15
+ https://api.audiobookshelf.org/#author
16
+ """
17
+
18
+ id_: Annotated[str, Alias("id")]
19
+ name: str
20
+
21
+
22
+ @dataclass(kw_only=True)
23
+ class Author(AuthorMinified):
24
+ """Author."""
25
+
26
+ asin: str | None = None
27
+ description: str | None = None
28
+ image_path: Annotated[str | None, Alias("imagePath")] = None
29
+ added_at: Annotated[int, Alias("addedAt")] # ms epoch
30
+ updated_at: Annotated[int, Alias("updatedAt")] # ms epoch
31
+
32
+
33
+ @dataclass(kw_only=True)
34
+ class AuthorExpanded(Author):
35
+ """ABSAuthorExpanded."""
36
+
37
+ num_books: Annotated[int, Alias("numBooks")]
38
+
39
+
40
+ @dataclass(kw_only=True)
41
+ class Narrator(_BaseModel):
42
+ """Narrator."""
43
+
44
+ id_: Annotated[str, Alias("id")]
45
+ name: str
46
+ num_books: Annotated[int, Alias("numBooks")]