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,72 @@
1
+ """Client library for Audiobookshelf."""
2
+
3
+ from aiohttp.client_exceptions import ClientResponseError, InvalidUrlClientError
4
+
5
+ from aioaudiobookshelf.client import AdminClient, SessionConfiguration, SocketClient, UserClient
6
+ from aioaudiobookshelf.exceptions import LoginError
7
+ from aioaudiobookshelf.schema.calls_login import LoginParameters, LoginResponse
8
+
9
+ __version__ = "0.1.0"
10
+
11
+
12
+ async def _get_login_response(
13
+ *, session_config: SessionConfiguration, username: str, password: str
14
+ ) -> LoginResponse:
15
+ login_request = LoginParameters(username=username, password=password).to_dict()
16
+
17
+ try:
18
+ resp = await session_config.session.post(
19
+ f"{session_config.url}/login",
20
+ json=login_request,
21
+ ssl=session_config.verify_ssl,
22
+ raise_for_status=True,
23
+ )
24
+ except (ClientResponseError, InvalidUrlClientError) as exc:
25
+ raise LoginError from exc
26
+ return LoginResponse.from_json(await resp.read())
27
+
28
+
29
+ async def get_user_client(
30
+ *,
31
+ session_config: SessionConfiguration,
32
+ username: str,
33
+ password: str,
34
+ ) -> UserClient:
35
+ """Get a user client."""
36
+ login_response = await _get_login_response(
37
+ session_config=session_config, username=username, password=password
38
+ )
39
+
40
+ return UserClient(session_config=session_config, login_response=login_response)
41
+
42
+
43
+ async def get_user_and_socket_client(
44
+ *,
45
+ session_config: SessionConfiguration,
46
+ username: str,
47
+ password: str,
48
+ ) -> tuple[UserClient, SocketClient]:
49
+ """Get user and socket client."""
50
+ login_response = await _get_login_response(
51
+ session_config=session_config, username=username, password=password
52
+ )
53
+
54
+ user_client = UserClient(session_config=session_config, login_response=login_response)
55
+ if not session_config.token:
56
+ session_config.token = user_client.token
57
+ socket_client = SocketClient(session_config=session_config)
58
+ return user_client, socket_client
59
+
60
+
61
+ async def get_admin_client(
62
+ *,
63
+ session_config: SessionConfiguration,
64
+ username: str,
65
+ password: str,
66
+ ) -> UserClient:
67
+ """Get a admin client."""
68
+ login_response = await _get_login_response(
69
+ session_config=session_config, username=username, password=password
70
+ )
71
+
72
+ return AdminClient(session_config=session_config, login_response=login_response)
@@ -0,0 +1,189 @@
1
+ """Clients for Audiobookshelf."""
2
+
3
+ import logging
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ import socketio
9
+ import socketio.exceptions
10
+ from aiohttp import ClientSession
11
+
12
+ from aioaudiobookshelf.exceptions import BadUserError
13
+ from aioaudiobookshelf.schema.events_socket import (
14
+ LibraryItemRemoved,
15
+ PodcastEpisodeDownload,
16
+ UserItemProgressUpdatedEvent,
17
+ )
18
+ from aioaudiobookshelf.schema.library import LibraryItemExpanded
19
+ from aioaudiobookshelf.schema.media_progress import MediaProgress
20
+ from aioaudiobookshelf.schema.user import User, UserType
21
+
22
+ from .authors import AuthorsClient
23
+ from .collections_ import CollectionsClient
24
+ from .items import ItemsClient
25
+ from .libraries import LibrariesClient
26
+ from .me import MeClient
27
+ from .playlists import PlaylistsClient
28
+ from .podcasts import PodcastsClient
29
+ from .series import SeriesClient
30
+ from .session import SessionClient
31
+
32
+
33
+ @dataclass(kw_only=True)
34
+ class SessionConfiguration:
35
+ """Session configuration for abs client."""
36
+
37
+ session: ClientSession
38
+ url: str
39
+ verify_ssl: bool = True
40
+ token: str = ""
41
+ pagination_items_per_page: int = 10
42
+ logger: logging.Logger | None = None
43
+
44
+ @property
45
+ def headers(self) -> dict[str, str]:
46
+ """Session headers."""
47
+ if self.token is None:
48
+ raise RuntimeError("Token not set.")
49
+ return {"Authorization": f"Bearer {self.token}"}
50
+
51
+ def __post_init__(self) -> None:
52
+ """Post init."""
53
+ self.url = self.url.rstrip("/")
54
+
55
+
56
+ class UserClient(
57
+ LibrariesClient,
58
+ ItemsClient,
59
+ CollectionsClient,
60
+ PlaylistsClient,
61
+ MeClient,
62
+ AuthorsClient,
63
+ SeriesClient,
64
+ SessionClient,
65
+ PodcastsClient,
66
+ ):
67
+ """Client which uses endpoints accessible to a user."""
68
+
69
+ def _verify_user(self) -> None:
70
+ if self.user.type_ not in [UserType.ADMIN, UserType.ROOT, UserType.USER]:
71
+ raise BadUserError
72
+
73
+
74
+ class AdminClient(UserClient):
75
+ """Client which uses endpoints accessible to users and admins."""
76
+
77
+ def _verify_user(self) -> None:
78
+ if self.user.type_ not in [UserType.ADMIN, UserType.ROOT]:
79
+ raise BadUserError
80
+
81
+
82
+ class SocketClient:
83
+ """Client for connecting to abs' socket."""
84
+
85
+ def __init__(self, session_config: SessionConfiguration) -> None:
86
+ """Init SocketClient."""
87
+ self.session_config = session_config
88
+
89
+ self.client = socketio.AsyncClient(
90
+ reconnection=True,
91
+ reconnection_attempts=0,
92
+ handle_sigint=False,
93
+ ssl_verify=self.session_config.verify_ssl,
94
+ )
95
+
96
+ self.set_item_callbacks()
97
+ self.set_user_callbacks()
98
+ self.set_podcast_episode_download_callbacks()
99
+
100
+ def set_item_callbacks(
101
+ self,
102
+ *,
103
+ on_item_added: Callable[[LibraryItemExpanded], Any] | None = None,
104
+ on_item_updated: Callable[[LibraryItemExpanded], Any] | None = None,
105
+ on_item_removed: Callable[[LibraryItemRemoved], Any] | None = None,
106
+ on_items_added: Callable[[list[LibraryItemExpanded]], Any] | None = None,
107
+ on_items_updated: Callable[[list[LibraryItemExpanded]], Any] | None = None,
108
+ ) -> None:
109
+ """Set item callbacks."""
110
+ self.on_item_added = on_item_added
111
+ self.on_item_updated = on_item_updated
112
+ self.on_item_removed = on_item_removed
113
+ self.on_items_added = on_items_added
114
+ self.on_items_updated = on_items_updated
115
+
116
+ def set_user_callbacks(
117
+ self,
118
+ *,
119
+ on_user_updated: Callable[[User], Any] | None = None,
120
+ on_user_item_progress_updated: Callable[[str, MediaProgress], Any] | None = None,
121
+ ) -> None:
122
+ """Set user callbacks."""
123
+ self.on_user_updated = on_user_updated
124
+ self.on_user_item_progress_updated = on_user_item_progress_updated
125
+
126
+ def set_podcast_episode_download_callbacks(
127
+ self, *, on_episode_download_finished: Callable[[PodcastEpisodeDownload], Any] | None = None
128
+ ) -> None:
129
+ """Set podcast episode download callbacks."""
130
+ self.on_episode_download_finished = on_episode_download_finished
131
+
132
+ async def init_client(self) -> None:
133
+ """Initialize the client."""
134
+ self.client.on("connect", handler=self._on_connect)
135
+
136
+ self.client.on("user_updated", handler=self._on_user_updated)
137
+ self.client.on("user_item_progress_updated", handler=self._on_user_item_progress_updated)
138
+
139
+ self.client.on("item_added", handler=self._on_item_added)
140
+ self.client.on("item_updated", handler=self._on_item_updated)
141
+ self.client.on("item_removed", handler=self._on_item_removed)
142
+ self.client.on("items_added", handler=self._on_items_added)
143
+ self.client.on("items_updated", handler=self._on_items_updated)
144
+
145
+ self.client.on("episode_download_finished", handler=self._on_episode_download_finished)
146
+
147
+ await self.client.connect(url=self.session_config.url)
148
+
149
+ async def shutdown(self) -> None:
150
+ """Shutdown client (disconnect, or stop reconnect attempt)."""
151
+ await self.client.shutdown()
152
+
153
+ logout = shutdown
154
+
155
+ async def _on_connect(self) -> None:
156
+ await self.client.emit(event="auth", data=self.session_config.token)
157
+
158
+ async def _on_user_updated(self, data: dict[str, Any]) -> None:
159
+ if self.on_user_updated is not None:
160
+ await self.on_user_updated(User.from_dict(data))
161
+
162
+ async def _on_user_item_progress_updated(self, data: dict[str, Any]) -> None:
163
+ if self.on_user_item_progress_updated is not None:
164
+ event = UserItemProgressUpdatedEvent.from_dict(data)
165
+ await self.on_user_item_progress_updated(event.id_, event.data)
166
+
167
+ async def _on_item_added(self, data: dict[str, Any]) -> None:
168
+ if self.on_item_added is not None:
169
+ await self.on_item_added(LibraryItemExpanded.from_dict(data))
170
+
171
+ async def _on_item_updated(self, data: dict[str, Any]) -> None:
172
+ if self.on_item_updated is not None:
173
+ await self.on_item_updated(LibraryItemExpanded.from_dict(data))
174
+
175
+ async def _on_item_removed(self, data: dict[str, Any]) -> None:
176
+ if self.on_item_removed is not None:
177
+ await self.on_item_removed(LibraryItemRemoved.from_dict(data))
178
+
179
+ async def _on_items_added(self, data: list[dict[str, Any]]) -> None:
180
+ if self.on_items_added is not None:
181
+ await self.on_items_added([LibraryItemExpanded.from_dict(x) for x in data])
182
+
183
+ async def _on_items_updated(self, data: list[dict[str, Any]]) -> None:
184
+ if self.on_items_updated is not None:
185
+ await self.on_items_updated([LibraryItemExpanded.from_dict(x) for x in data])
186
+
187
+ async def _on_episode_download_finished(self, data: dict[str, Any]) -> None:
188
+ if self.on_episode_download_finished is not None:
189
+ await self.on_episode_download_finished(PodcastEpisodeDownload.from_dict(data))
@@ -0,0 +1,110 @@
1
+ """BaseClient."""
2
+
3
+ import logging
4
+ from abc import abstractmethod
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from aiohttp.client_exceptions import ClientResponseError
8
+
9
+ if TYPE_CHECKING:
10
+ from aioaudiobookshelf.client import SessionConfiguration
11
+ from aioaudiobookshelf.exceptions import ApiError
12
+ from aioaudiobookshelf.schema.calls_login import LoginResponse
13
+
14
+
15
+ class BaseClient:
16
+ """Base for clients."""
17
+
18
+ def __init__(
19
+ self, session_config: "SessionConfiguration", login_response: LoginResponse
20
+ ) -> None:
21
+ self.session_config = session_config
22
+ self.user = login_response.user
23
+ if not self.session_config.token:
24
+ self.session_config.token = login_response.user.token
25
+ self._token = self.session_config.token
26
+
27
+ if self.session_config.logger is None:
28
+ self.logger = logging.getLogger(__name__)
29
+ logging.basicConfig()
30
+ self.logger.setLevel(logging.DEBUG)
31
+ else:
32
+ self.logger = self.session_config.logger
33
+
34
+ self.logger.debug(
35
+ "Initialized client %s, token: %s",
36
+ self.__class__.__name__,
37
+ self._token,
38
+ )
39
+
40
+ self._verify_user()
41
+
42
+ @property
43
+ def token(self) -> str:
44
+ return self._token
45
+
46
+ @abstractmethod
47
+ def _verify_user(self) -> None:
48
+ """Verify if user has enough permissions for endpoints in use."""
49
+
50
+ async def _post(
51
+ self,
52
+ endpoint: str,
53
+ data: dict[str, Any] | None = None,
54
+ ) -> bytes:
55
+ """POST request to abs api."""
56
+ try:
57
+ response = await self.session_config.session.post(
58
+ f"{self.session_config.url}/{endpoint}",
59
+ json=data,
60
+ ssl=self.session_config.verify_ssl,
61
+ headers=self.session_config.headers,
62
+ raise_for_status=True,
63
+ )
64
+ except ClientResponseError as exc:
65
+ raise ApiError(f"API POST call to {endpoint} failed.") from exc
66
+ return await response.read()
67
+
68
+ async def _get(self, endpoint: str, params: dict[str, str | int] | None = None) -> bytes:
69
+ """GET request to abs api."""
70
+ response = await self.session_config.session.get(
71
+ f"{self.session_config.url}/{endpoint}",
72
+ params=params,
73
+ ssl=self.session_config.verify_ssl,
74
+ headers=self.session_config.headers,
75
+ )
76
+ status = response.status
77
+ if response.content_type == "application/json" and status == 200:
78
+ return await response.read()
79
+ if status == 404:
80
+ return b""
81
+ raise ApiError(f"API GET call to {endpoint} failed.")
82
+
83
+ async def _patch(self, endpoint: str, data: dict[str, Any] | None = None) -> None:
84
+ """PATCH request to abs api."""
85
+ try:
86
+ await self.session_config.session.patch(
87
+ f"{self.session_config.url}/{endpoint}",
88
+ json=data,
89
+ ssl=self.session_config.verify_ssl,
90
+ headers=self.session_config.headers,
91
+ raise_for_status=True,
92
+ )
93
+ except ClientResponseError as exc:
94
+ raise ApiError(f"API PATCH call to {endpoint} failed.") from exc
95
+
96
+ async def _delete(self, endpoint: str) -> None:
97
+ """DELETE request to abs api."""
98
+ try:
99
+ await self.session_config.session.delete(
100
+ endpoint,
101
+ ssl=self.session_config.verify_ssl,
102
+ headers=self.session_config.headers,
103
+ raise_for_status=True,
104
+ )
105
+ except ClientResponseError as exc:
106
+ raise ApiError(f"API DELETE call to {endpoint} failed.") from exc
107
+
108
+ async def logout(self) -> None:
109
+ """Logout client."""
110
+ await self._post("logout")
@@ -0,0 +1,34 @@
1
+ """Calls to /api/authors."""
2
+
3
+ from aioaudiobookshelf.client._base import BaseClient
4
+ from aioaudiobookshelf.schema.author import Author
5
+ from aioaudiobookshelf.schema.calls_authors import AuthorWithItems, AuthorWithItemsAndSeries
6
+
7
+
8
+ class AuthorsClient(BaseClient):
9
+ """AuthorsClient."""
10
+
11
+ async def get_author(
12
+ self, *, author_id: str, include_items: bool = False, include_series: bool = False
13
+ ) -> Author | AuthorWithItems | AuthorWithItemsAndSeries:
14
+ """Get an author.
15
+
16
+ Include series always includes items.
17
+ """
18
+ response_cls: type[Author | AuthorWithItems | AuthorWithItemsAndSeries] = Author
19
+ if include_series:
20
+ include_items = True
21
+ endpoint = f"/api/authors/{author_id}"
22
+ if include_items:
23
+ endpoint += "?include=items"
24
+ response_cls = AuthorWithItems
25
+ if include_series:
26
+ endpoint += ",series"
27
+ response_cls = AuthorWithItemsAndSeries
28
+
29
+ response = await self._get(endpoint)
30
+ return response_cls.from_json(response)
31
+
32
+ # update author
33
+ # match author
34
+ # get author image
@@ -0,0 +1,28 @@
1
+ """Calls to /api/collections."""
2
+
3
+ from aioaudiobookshelf.schema.calls_collections import AllCollectionsResponse
4
+ from aioaudiobookshelf.schema.collection import CollectionExpanded
5
+
6
+ from ._base import BaseClient
7
+
8
+
9
+ class CollectionsClient(BaseClient):
10
+ """CollectionsClient."""
11
+
12
+ # create collection
13
+
14
+ async def get_all_collections(self) -> list[CollectionExpanded]:
15
+ """Get all collections accessible to user."""
16
+ data = await self._get(endpoint="/api/collections")
17
+ return AllCollectionsResponse.from_json(data).collections
18
+
19
+ async def get_collection(self, *, collection_id: str) -> CollectionExpanded:
20
+ """Get a collection."""
21
+ data = await self._get(endpoint=f"/api/collections/{collection_id}")
22
+ return CollectionExpanded.from_json(data)
23
+
24
+ # update
25
+ # delete
26
+ # add book
27
+ # remove book
28
+ # batch add + remove
@@ -0,0 +1,108 @@
1
+ """Calls to /api/items."""
2
+
3
+ from aioaudiobookshelf.client._base import BaseClient
4
+ from aioaudiobookshelf.schema.calls_items import (
5
+ LibraryItemsBatchBookResponse,
6
+ LibraryItemsBatchParameters,
7
+ LibraryItemsBatchPodcastResponse,
8
+ PlaybackSessionParameters,
9
+ )
10
+ from aioaudiobookshelf.schema.library import (
11
+ LibraryItemBook,
12
+ LibraryItemExpandedBook,
13
+ LibraryItemExpandedPodcast,
14
+ LibraryItemPodcast,
15
+ )
16
+ from aioaudiobookshelf.schema.session import PlaybackSessionExpanded
17
+
18
+
19
+ class ItemsClient(BaseClient):
20
+ """ItemsClient."""
21
+
22
+ # delete all items (admin)
23
+
24
+ async def get_library_item_book(
25
+ self, *, book_id: str, expanded: bool = False
26
+ ) -> LibraryItemBook | LibraryItemExpandedBook:
27
+ """Get book library item.
28
+
29
+ We only support expanded as parameter.
30
+ """
31
+ data = await self._get(f"/api/items/{book_id}?expanded={int(expanded)}")
32
+ if not expanded:
33
+ return LibraryItemBook.from_json(data)
34
+ return LibraryItemExpandedBook.from_json(data)
35
+
36
+ async def get_library_item_podcast(
37
+ self, *, podcast_id: str, expanded: bool = False
38
+ ) -> LibraryItemPodcast | LibraryItemExpandedPodcast:
39
+ """Get book library item.
40
+
41
+ We only support expanded as parameter.
42
+ """
43
+ data = await self._get(f"/api/items/{podcast_id}?expanded={int(expanded)}")
44
+ if not expanded:
45
+ return LibraryItemPodcast.from_json(data)
46
+ return LibraryItemExpandedPodcast.from_json(data)
47
+
48
+ # delete library item
49
+ # update library item media
50
+ # get item cover
51
+ # upload cover
52
+ # update cover
53
+ # remove cover
54
+ # match lib item
55
+
56
+ async def get_playback_session(
57
+ self,
58
+ *,
59
+ session_parameters: PlaybackSessionParameters,
60
+ item_id: str,
61
+ episode_id: str | None = None,
62
+ ) -> PlaybackSessionExpanded:
63
+ """Play a media item."""
64
+ endpoint = f"/api/items/{item_id}/play"
65
+ if episode_id is not None:
66
+ endpoint += f"/{episode_id}"
67
+ response = await self._post(endpoint, data=session_parameters.to_dict())
68
+ return PlaybackSessionExpanded.from_json(response)
69
+
70
+ # update audio track
71
+ # scan item
72
+ # get tone metadata
73
+ # update chapters
74
+ # tone scan
75
+
76
+ async def _get_libray_item_batch(
77
+ self, *, item_ids: list[str] | LibraryItemsBatchParameters
78
+ ) -> bytes:
79
+ if isinstance(item_ids, list):
80
+ if not item_ids:
81
+ return b""
82
+ params = LibraryItemsBatchParameters(library_item_ids=item_ids)
83
+ else:
84
+ if not item_ids.library_item_ids:
85
+ return b""
86
+ params = item_ids
87
+
88
+ return await self._post("/api/items/batch/get", data=params.to_dict())
89
+
90
+ async def get_library_item_batch_book(
91
+ self, *, item_ids: list[str] | LibraryItemsBatchParameters
92
+ ) -> list[LibraryItemExpandedBook]:
93
+ """Get multiple library items at once. Always expanded."""
94
+ data = await self._get_libray_item_batch(item_ids=item_ids)
95
+ if not data:
96
+ return []
97
+ return LibraryItemsBatchBookResponse.from_json(data).library_items
98
+
99
+ async def get_library_item_batch_podcast(
100
+ self, *, item_ids: list[str] | LibraryItemsBatchParameters
101
+ ) -> list[LibraryItemExpandedPodcast]:
102
+ """Get multiple library items at once. Always expanded."""
103
+ data = await self._get_libray_item_batch(item_ids=item_ids)
104
+ if not data:
105
+ return []
106
+ return LibraryItemsBatchPodcastResponse.from_json(data).library_items
107
+
108
+ # batch delete, update, quick match