aioaudiobookshelf 0.1.7__tar.gz → 0.1.9__tar.gz

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.
Files changed (54) hide show
  1. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/PKG-INFO +1 -1
  2. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/__init__.py +8 -26
  3. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/client/__init__.py +50 -28
  4. aioaudiobookshelf-0.1.9/aioaudiobookshelf/client/_base.py +197 -0
  5. aioaudiobookshelf-0.1.9/aioaudiobookshelf/client/session_configuration.py +105 -0
  6. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/exceptions.py +12 -0
  7. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/helpers.py +29 -0
  8. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/book.py +1 -1
  9. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/calls_login.py +3 -0
  10. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/user.py +7 -1
  11. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf.egg-info/PKG-INFO +1 -1
  12. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf.egg-info/SOURCES.txt +1 -0
  13. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/pyproject.toml +2 -2
  14. aioaudiobookshelf-0.1.7/aioaudiobookshelf/client/_base.py +0 -109
  15. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/LICENSE +0 -0
  16. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/README.md +0 -0
  17. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/client/authors.py +0 -0
  18. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/client/collections_.py +0 -0
  19. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/client/items.py +0 -0
  20. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/client/libraries.py +0 -0
  21. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/client/me.py +0 -0
  22. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/client/playlists.py +0 -0
  23. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/client/podcasts.py +0 -0
  24. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/client/series.py +0 -0
  25. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/client/session.py +0 -0
  26. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/__init__.py +0 -0
  27. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/audio.py +0 -0
  28. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/author.py +0 -0
  29. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/calls_authors.py +0 -0
  30. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/calls_collections.py +0 -0
  31. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/calls_items.py +0 -0
  32. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/calls_library.py +0 -0
  33. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/calls_me.py +0 -0
  34. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/calls_playlists.py +0 -0
  35. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/calls_series.py +0 -0
  36. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/calls_session.py +0 -0
  37. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/collection.py +0 -0
  38. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/events_socket.py +0 -0
  39. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/file.py +0 -0
  40. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/folder.py +0 -0
  41. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/library.py +0 -0
  42. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/media_progress.py +0 -0
  43. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/playlist.py +0 -0
  44. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/podcast.py +0 -0
  45. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/series.py +0 -0
  46. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/series_books.py +0 -0
  47. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/server.py +0 -0
  48. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/session.py +0 -0
  49. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf/schema/shelf.py +0 -0
  50. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf.egg-info/dependency_links.txt +0 -0
  51. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf.egg-info/not-zip-safe +0 -0
  52. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf.egg-info/requires.txt +0 -0
  53. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/aioaudiobookshelf.egg-info/top_level.txt +0 -0
  54. {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.9}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aioaudiobookshelf
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Summary: Async library for Audiobookshelf
5
5
  Author-email: Fabian Munkes <105975993+fmunkes@users.noreply.github.com>
6
6
  License: Apache-2.0
@@ -2,29 +2,13 @@
2
2
 
3
3
  from aiohttp.client_exceptions import ClientResponseError, InvalidUrlClientError
4
4
 
5
- from aioaudiobookshelf.client import AdminClient, SessionConfiguration, SocketClient, UserClient
5
+ from aioaudiobookshelf.client import AdminClient, SocketClient, UserClient
6
+ from aioaudiobookshelf.client.session_configuration import SessionConfiguration
6
7
  from aioaudiobookshelf.exceptions import LoginError, TokenIsMissingError
7
- from aioaudiobookshelf.schema.calls_login import AuthorizeResponse, LoginParameters, LoginResponse
8
+ from aioaudiobookshelf.helpers import get_login_response
9
+ from aioaudiobookshelf.schema.calls_login import AuthorizeResponse
8
10
 
9
- __version__ = "0.1.7"
10
-
11
-
12
- async def _get_login_response(
13
- *, session_config: SessionConfiguration, username: str, password: str
14
- ) -> LoginResponse:
15
- """Login via username and password."""
16
- login_request = LoginParameters(username=username, password=password).to_dict()
17
-
18
- try:
19
- resp = await session_config.session.post(
20
- f"{session_config.url}/login",
21
- json=login_request,
22
- ssl=session_config.verify_ssl,
23
- raise_for_status=True,
24
- )
25
- except (ClientResponseError, InvalidUrlClientError) as exc:
26
- raise LoginError from exc
27
- return LoginResponse.from_json(await resp.read())
11
+ __version__ = "0.1.9"
28
12
 
29
13
 
30
14
  async def _get_authorize_response(*, session_config: SessionConfiguration) -> AuthorizeResponse:
@@ -70,7 +54,7 @@ async def get_user_client(
70
54
  password: str,
71
55
  ) -> UserClient:
72
56
  """Get a user client."""
73
- login_response = await _get_login_response(
57
+ login_response = await get_login_response(
74
58
  session_config=session_config, username=username, password=password
75
59
  )
76
60
 
@@ -84,13 +68,11 @@ async def get_user_and_socket_client(
84
68
  password: str,
85
69
  ) -> tuple[UserClient, SocketClient]:
86
70
  """Get user and socket client."""
87
- login_response = await _get_login_response(
71
+ login_response = await get_login_response(
88
72
  session_config=session_config, username=username, password=password
89
73
  )
90
74
 
91
75
  user_client = UserClient(session_config=session_config, login_response=login_response)
92
- if not session_config.token:
93
- session_config.token = user_client.token
94
76
  socket_client = SocketClient(session_config=session_config)
95
77
  return user_client, socket_client
96
78
 
@@ -102,7 +84,7 @@ async def get_admin_client(
102
84
  password: str,
103
85
  ) -> UserClient:
104
86
  """Get a admin client."""
105
- login_response = await _get_login_response(
87
+ login_response = await get_login_response(
106
88
  session_config=session_config, username=username, password=password
107
89
  )
108
90
 
@@ -2,14 +2,18 @@
2
2
 
3
3
  import logging
4
4
  from collections.abc import Callable
5
- from dataclasses import dataclass
6
5
  from typing import Any
7
6
 
8
7
  import socketio
9
8
  import socketio.exceptions
10
- from aiohttp import ClientSession
11
9
 
12
- from aioaudiobookshelf.exceptions import BadUserError, TokenIsMissingError
10
+ from aioaudiobookshelf.client.session_configuration import SessionConfiguration
11
+ from aioaudiobookshelf.exceptions import (
12
+ BadUserError,
13
+ RefreshTokenExpiredError,
14
+ ServiceUnavailableError,
15
+ TokenIsMissingError,
16
+ )
13
17
  from aioaudiobookshelf.schema.events_socket import (
14
18
  LibraryItemRemoved,
15
19
  PodcastEpisodeDownload,
@@ -30,29 +34,6 @@ from .series import SeriesClient
30
34
  from .session import SessionClient
31
35
 
32
36
 
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 | None = None
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 TokenIsMissingError("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
37
  class UserClient(
57
38
  LibrariesClient,
58
39
  ItemsClient,
@@ -82,7 +63,10 @@ class AdminClient(UserClient):
82
63
  class SocketClient:
83
64
  """Client for connecting to abs' socket."""
84
65
 
85
- def __init__(self, session_config: SessionConfiguration) -> None:
66
+ def __init__(
67
+ self,
68
+ session_config: SessionConfiguration,
69
+ ) -> None:
86
70
  """Init SocketClient."""
87
71
  self.session_config = session_config
88
72
 
@@ -93,9 +77,17 @@ class SocketClient:
93
77
  ssl_verify=self.session_config.verify_ssl,
94
78
  )
95
79
 
80
+ if self.session_config.logger is None:
81
+ self.logger = logging.getLogger(__name__)
82
+ logging.basicConfig()
83
+ self.logger.setLevel(logging.DEBUG)
84
+ else:
85
+ self.logger = self.session_config.logger
86
+
96
87
  self.set_item_callbacks()
97
88
  self.set_user_callbacks()
98
89
  self.set_podcast_episode_download_callbacks()
90
+ self.set_refresh_token_expired_callback()
99
91
 
100
92
  def set_item_callbacks(
101
93
  self,
@@ -129,9 +121,16 @@ class SocketClient:
129
121
  """Set podcast episode download callbacks."""
130
122
  self.on_episode_download_finished = on_episode_download_finished
131
123
 
124
+ def set_refresh_token_expired_callback(
125
+ self, *, on_refresh_token_expired: Callable[[], Any] | None = None
126
+ ) -> None:
127
+ """Set refresh token expired callback."""
128
+ self.on_refresh_token_expired = on_refresh_token_expired
129
+
132
130
  async def init_client(self) -> None:
133
131
  """Initialize the client."""
134
132
  self.client.on("connect", handler=self._on_connect)
133
+ self.client.on("connect_error", handler=self._on_connect_error)
135
134
 
136
135
  self.client.on("user_updated", handler=self._on_user_updated)
137
136
  self.client.on("user_item_progress_updated", handler=self._on_user_item_progress_updated)
@@ -153,7 +152,30 @@ class SocketClient:
153
152
  logout = shutdown
154
153
 
155
154
  async def _on_connect(self) -> None:
156
- await self.client.emit(event="auth", data=self.session_config.token)
155
+ """V2.26 and above: access token or api token."""
156
+ if self.session_config.access_token is not None:
157
+ token = self.session_config.access_token
158
+ else:
159
+ if self.session_config.token is None:
160
+ raise TokenIsMissingError
161
+ token = self.session_config.token
162
+ await self.client.emit(event="auth", data=token)
163
+ self.logger.debug("Socket connected.")
164
+
165
+ async def _on_connect_error(self, *_: Any) -> None:
166
+ if not self.session_config.auto_refresh or self.session_config.access_token is None:
167
+ return
168
+ # try to refresh token
169
+ self.logger.debug("Auto refreshing token")
170
+ try:
171
+ await self.session_config.refresh()
172
+ except RefreshTokenExpiredError:
173
+ if self.on_refresh_token_expired is not None:
174
+ await self.on_refresh_token_expired()
175
+ return
176
+ except ServiceUnavailableError:
177
+ # socketio will continue trying to reconnect.
178
+ return
157
179
 
158
180
  async def _on_user_updated(self, data: dict[str, Any]) -> None:
159
181
  if self.on_user_updated is not None:
@@ -0,0 +1,197 @@
1
+ """BaseClient."""
2
+
3
+ import logging
4
+ from abc import abstractmethod
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from aiohttp.client import ClientResponse
8
+ from aiohttp.client_exceptions import ClientResponseError
9
+
10
+ if TYPE_CHECKING:
11
+ from aioaudiobookshelf.client.session_configuration import SessionConfiguration
12
+ from aioaudiobookshelf.exceptions import AccessTokenExpiredError, ApiError, TokenIsMissingError
13
+ from aioaudiobookshelf.schema.calls_login import LoginResponse
14
+
15
+
16
+ class BaseClient:
17
+ """Base for clients."""
18
+
19
+ def __init__(
20
+ self, session_config: "SessionConfiguration", login_response: LoginResponse
21
+ ) -> None:
22
+ self.session_config = session_config
23
+ self.user = login_response.user
24
+ self.server_settings = login_response.server_settings
25
+
26
+ if not self.session_config.token and not self.session_config.refresh_token:
27
+ if login_response.user.refresh_token is not None:
28
+ assert login_response.user.access_token is not None
29
+ assert login_response.user.refresh_token is not None
30
+ self.session_config.refresh_token = login_response.user.refresh_token
31
+ self.session_config.access_token = login_response.user.access_token
32
+ elif login_response.user.token is not None:
33
+ assert login_response.user.token is not None
34
+ self.session_config.token = login_response.user.token
35
+
36
+ if self.session_config.logger is None:
37
+ self.logger = logging.getLogger(__name__)
38
+ logging.basicConfig()
39
+ self.logger.setLevel(logging.DEBUG)
40
+ else:
41
+ self.logger = self.session_config.logger
42
+
43
+ self.logger.debug(
44
+ "Initialized client %s",
45
+ self.__class__.__name__,
46
+ )
47
+
48
+ self._verify_user()
49
+
50
+ @property
51
+ def token(self) -> str:
52
+ if self.session_config.access_token is not None:
53
+ return self.session_config.access_token
54
+ if self.session_config.token is None:
55
+ raise TokenIsMissingError
56
+ return self.session_config.token
57
+
58
+ @abstractmethod
59
+ def _verify_user(self) -> None:
60
+ """Verify if user has enough permissions for endpoints in use."""
61
+
62
+ async def _post(
63
+ self,
64
+ endpoint: str,
65
+ data: dict[str, Any] | None = None,
66
+ ) -> bytes:
67
+ """POST request to abs api."""
68
+
69
+ async def _request() -> ClientResponse:
70
+ return await self.session_config.session.post(
71
+ f"{self.session_config.url}/{endpoint}",
72
+ json=data,
73
+ ssl=self.session_config.verify_ssl,
74
+ headers=self.session_config.headers,
75
+ raise_for_status=True,
76
+ )
77
+
78
+ try:
79
+ response = await _request()
80
+ except ClientResponseError as exc:
81
+ if exc.code == 401:
82
+ if self.session_config.auto_refresh:
83
+ self.logger.debug("Auto refreshing tokens.")
84
+ await self.refresh()
85
+ else:
86
+ raise AccessTokenExpiredError from exc
87
+ else:
88
+ raise ApiError(f"API POST call to {endpoint} failed.") from exc
89
+
90
+ # TODO: remove redundant clause
91
+ try:
92
+ response = await _request()
93
+ except ClientResponseError as exc:
94
+ raise ApiError(f"API POST call to {endpoint} failed.") from exc
95
+
96
+ return await response.read()
97
+
98
+ async def _get(self, endpoint: str, params: dict[str, str | int] | None = None) -> bytes:
99
+ """GET request to abs api."""
100
+
101
+ async def _request() -> ClientResponse:
102
+ return await self.session_config.session.get(
103
+ f"{self.session_config.url}/{endpoint}",
104
+ params=params,
105
+ ssl=self.session_config.verify_ssl,
106
+ headers=self.session_config.headers,
107
+ )
108
+
109
+ response = await _request()
110
+ if response.status == 401:
111
+ if self.session_config.auto_refresh:
112
+ self.logger.debug("Auto refreshing tokens.")
113
+ await self.refresh()
114
+ response = await _request()
115
+ else:
116
+ raise AccessTokenExpiredError
117
+
118
+ status = response.status
119
+ if response.content_type == "application/json" and status == 200:
120
+ return await response.read()
121
+ if status == 404:
122
+ return b""
123
+ raise ApiError(f"API GET call to {endpoint} failed.")
124
+
125
+ async def _patch(self, endpoint: str, data: dict[str, Any] | None = None) -> None:
126
+ """PATCH request to abs api."""
127
+
128
+ async def _request() -> None:
129
+ await self.session_config.session.patch(
130
+ f"{self.session_config.url}/{endpoint}",
131
+ json=data,
132
+ ssl=self.session_config.verify_ssl,
133
+ headers=self.session_config.headers,
134
+ raise_for_status=True,
135
+ )
136
+
137
+ try:
138
+ await _request()
139
+ except ClientResponseError as exc:
140
+ if exc.code == 401:
141
+ if self.session_config.auto_refresh:
142
+ self.logger.debug("Auto refreshing tokens.")
143
+ await self.refresh()
144
+ else:
145
+ raise AccessTokenExpiredError from exc
146
+ else:
147
+ raise ApiError(f"API PATCH call to {endpoint} failed.") from exc
148
+
149
+ try:
150
+ await _request()
151
+ except ClientResponseError as exc:
152
+ raise ApiError(f"API PATCH call to {endpoint} failed.") from exc
153
+
154
+ async def _delete(self, endpoint: str) -> None:
155
+ """DELETE request to abs api."""
156
+
157
+ async def _request() -> None:
158
+ await self.session_config.session.delete(
159
+ f"{self.session_config.url}/{endpoint}",
160
+ ssl=self.session_config.verify_ssl,
161
+ headers=self.session_config.headers,
162
+ raise_for_status=True,
163
+ )
164
+
165
+ try:
166
+ await _request()
167
+ except ClientResponseError as exc:
168
+ if exc.code == 401:
169
+ if self.session_config.auto_refresh:
170
+ self.logger.debug("Auto refreshing tokens.")
171
+ await self.refresh()
172
+ else:
173
+ raise AccessTokenExpiredError from exc
174
+ else:
175
+ raise ApiError(f"API DELETE call to {endpoint} failed.") from exc
176
+
177
+ try:
178
+ await _request()
179
+ except ClientResponseError as exc:
180
+ raise ApiError(f"API DELETE call to {endpoint} failed.") from exc
181
+
182
+ async def refresh(self) -> None:
183
+ """Refresh tokens."""
184
+ await self.session_config.refresh()
185
+
186
+ async def logout(self) -> None:
187
+ """Logout client."""
188
+ if self.session_config.refresh_token is not None:
189
+ # v2.26 and above
190
+ await self.session_config.session.post(
191
+ f"{self.session_config.url}/logout",
192
+ ssl=self.session_config.verify_ssl,
193
+ headers=self.session_config.headers_refresh_logout,
194
+ raise_for_status=True,
195
+ )
196
+ else:
197
+ await self._post("logout")
@@ -0,0 +1,105 @@
1
+ """Session Configuration."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from dataclasses import dataclass
6
+
7
+ from aiohttp.client import ClientSession
8
+ from aiohttp.client_exceptions import ClientResponseError
9
+
10
+ from aioaudiobookshelf.exceptions import (
11
+ RefreshTokenExpiredError,
12
+ ServiceUnavailableError,
13
+ TokenIsMissingError,
14
+ )
15
+ from aioaudiobookshelf.helpers import get_login_response
16
+ from aioaudiobookshelf.schema.calls_login import RefreshResponse
17
+
18
+
19
+ @dataclass(kw_only=True)
20
+ class SessionConfiguration:
21
+ """Session configuration for abs client.
22
+
23
+ Relevant token information for v2.26 and above:
24
+ https://github.com/advplyr/audiobookshelf/discussions/4460
25
+ """
26
+
27
+ session: ClientSession
28
+ url: str
29
+ verify_ssl: bool = True
30
+ token: str | None = None # pre v2.26 token or api token if > v2.26
31
+ access_token: str | None = None # > v2.26
32
+ refresh_token: str | None = None # > v2.26
33
+ auto_refresh: bool = True # automatically refresh access token, should it be expired.
34
+ pagination_items_per_page: int = 10
35
+ logger: logging.Logger | None = None
36
+
37
+ @property
38
+ def headers(self) -> dict[str, str]:
39
+ """Session headers.
40
+
41
+ These are normal request headers.
42
+ """
43
+ if self.token is not None:
44
+ return {"Authorization": f"Bearer {self.token}"}
45
+ if self.access_token is not None:
46
+ return {"Authorization": f"Bearer {self.access_token}"}
47
+ raise TokenIsMissingError("Token not set.")
48
+
49
+ @property
50
+ def headers_refresh_logout(self) -> dict[str, str]:
51
+ """Session headers for /auth/refresh and /logout.
52
+
53
+ Only v2.26 and above.
54
+ """
55
+ if self.refresh_token is None:
56
+ raise TokenIsMissingError("Refresh token not set.")
57
+ return {"x-refresh-token": self.refresh_token}
58
+
59
+ def __post_init__(self) -> None:
60
+ """Post init."""
61
+ self.url = self.url.rstrip("/")
62
+ self.__refresh_lock = asyncio.Lock()
63
+
64
+ async def refresh(self) -> None:
65
+ """Refresh access_token with refresh token.
66
+
67
+ v2.26 and above
68
+ """
69
+ if self.__refresh_lock.locked():
70
+ return
71
+ async with self.__refresh_lock:
72
+ try:
73
+ endpoint = "auth/refresh"
74
+ response = await self.session.post(
75
+ f"{self.url}/{endpoint}",
76
+ ssl=self.verify_ssl,
77
+ headers=self.headers_refresh_logout,
78
+ raise_for_status=True,
79
+ )
80
+ except ClientResponseError as err:
81
+ if err.code == 503:
82
+ raise ServiceUnavailableError from err
83
+ raise RefreshTokenExpiredError from err
84
+ data = await response.read()
85
+ refresh_response = RefreshResponse.from_json(data)
86
+ assert refresh_response.user.access_token is not None
87
+ assert refresh_response.user.refresh_token is not None
88
+ self.access_token = refresh_response.user.access_token
89
+ self.refresh_token = refresh_response.user.refresh_token
90
+
91
+ async def authenticate(self, *, username: str, password: str) -> None:
92
+ """Relogin and update tokens if refresh token expired."""
93
+ async with self.__refresh_lock:
94
+ login_response = await get_login_response(
95
+ session_config=self, username=username, password=password
96
+ )
97
+ if login_response.user.access_token is None:
98
+ # pre v2.26
99
+ assert login_response.user.token is not None
100
+ self.token = login_response.user.token
101
+ return
102
+ assert login_response.user.access_token is not None
103
+ assert login_response.user.refresh_token is not None
104
+ self.access_token = login_response.user.access_token
105
+ self.refresh_token = login_response.user.refresh_token
@@ -15,3 +15,15 @@ class ApiError(Exception):
15
15
 
16
16
  class TokenIsMissingError(Exception):
17
17
  """Exception raised if token is missing."""
18
+
19
+
20
+ class AccessTokenExpiredError(Exception):
21
+ """Exception raised if access token expired."""
22
+
23
+
24
+ class RefreshTokenExpiredError(Exception):
25
+ """Exception raised if refresh token expired."""
26
+
27
+
28
+ class ServiceUnavailableError(Exception):
29
+ """Raised if service is not available."""
@@ -3,6 +3,15 @@
3
3
  import base64
4
4
  import urllib.parse
5
5
  from enum import StrEnum
6
+ from typing import TYPE_CHECKING
7
+
8
+ from aiohttp.client_exceptions import ClientResponseError, InvalidUrlClientError
9
+
10
+ from aioaudiobookshelf.exceptions import LoginError
11
+ from aioaudiobookshelf.schema.calls_login import LoginParameters, LoginResponse
12
+
13
+ if TYPE_CHECKING:
14
+ from aioaudiobookshelf.client.session_configuration import SessionConfiguration
6
15
 
7
16
 
8
17
  class FilterGroup(StrEnum):
@@ -54,3 +63,23 @@ def get_library_filter_string(
54
63
  return f"{filter_group.value}.{_encoded}"
55
64
 
56
65
  raise NotImplementedError(f"The {filter_group=} is not yet implemented.")
66
+
67
+
68
+ async def get_login_response(
69
+ *, session_config: "SessionConfiguration", username: str, password: str
70
+ ) -> LoginResponse:
71
+ """Login via username and password."""
72
+ login_request = LoginParameters(username=username, password=password).to_dict()
73
+
74
+ try:
75
+ resp = await session_config.session.post(
76
+ f"{session_config.url}/login",
77
+ json=login_request,
78
+ ssl=session_config.verify_ssl,
79
+ raise_for_status=True,
80
+ # adapt > v2.26.0 https://github.com/advplyr/audiobookshelf/discussions/4460
81
+ headers={"x-return-tokens": "true"},
82
+ )
83
+ except (ClientResponseError, InvalidUrlClientError) as exc:
84
+ raise LoginError from exc
85
+ return LoginResponse.from_json(await resp.read())
@@ -18,7 +18,7 @@ class EBookFile(_BaseModel):
18
18
 
19
19
  ino: str
20
20
  metadata: FileMetadata
21
- ebook_format: Annotated[str, Alias("ebookFormat")]
21
+ ebook_format: Annotated[str | None, Alias("ebookFormat")] = None
22
22
  added_at: Annotated[int, Alias("addedAt")] # time in ms since unix epoch
23
23
  updated_at: Annotated[int, Alias("updatedAt")] # time in ms since unix epoch
24
24
 
@@ -30,3 +30,6 @@ class LoginResponse(DataClassJSONMixin):
30
30
 
31
31
  # api/authorize, if token is used for authorization
32
32
  AuthorizeResponse = LoginResponse
33
+
34
+ # auth/refresh, new in v2.26
35
+ RefreshResponse = LoginResponse
@@ -52,7 +52,13 @@ class _UserBase(_BaseModel):
52
52
  class User(_UserBase):
53
53
  """User."""
54
54
 
55
- token: str
55
+ # see https://github.com/advplyr/audiobookshelf/discussions/4460
56
+ # new in v2.26.0 old token system will be removed in the future
57
+ # we make them optional to have some backwards compatibility
58
+ token: str | None = None
59
+ # will only be returned if x-return-tokens is set to true in header
60
+ refresh_token: Annotated[str | None, Alias("refreshToken")] = None
61
+ access_token: Annotated[str | None, Alias("accessToken")] = None
56
62
 
57
63
  media_progress: Annotated[list[MediaProgress], Alias("mediaProgress")]
58
64
  series_hide_from_continue_listening: Annotated[
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aioaudiobookshelf
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Summary: Async library for Audiobookshelf
5
5
  Author-email: Fabian Munkes <105975993+fmunkes@users.noreply.github.com>
6
6
  License: Apache-2.0
@@ -21,6 +21,7 @@ aioaudiobookshelf/client/playlists.py
21
21
  aioaudiobookshelf/client/podcasts.py
22
22
  aioaudiobookshelf/client/series.py
23
23
  aioaudiobookshelf/client/session.py
24
+ aioaudiobookshelf/client/session_configuration.py
24
25
  aioaudiobookshelf/schema/__init__.py
25
26
  aioaudiobookshelf/schema/audio.py
26
27
  aioaudiobookshelf/schema/author.py
@@ -18,7 +18,7 @@ description = "Async library for Audiobookshelf"
18
18
  license = {text = "Apache-2.0"}
19
19
  readme = "README.md"
20
20
  requires-python = ">=3.12"
21
- version = "0.1.7"
21
+ version = "0.1.9"
22
22
 
23
23
  [project.optional-dependencies]
24
24
  test = [
@@ -133,7 +133,7 @@ known-first-party = ["music_assistant"]
133
133
  max-complexity = 25
134
134
 
135
135
  [tool.bumpver]
136
- current_version = "0.1.7"
136
+ current_version = "0.1.9"
137
137
  version_pattern = "MAJOR.MINOR.PATCH"
138
138
  commit_message = "bump version {old_version} -> {new_version}"
139
139
  tag_message = "{new_version}"
@@ -1,109 +0,0 @@
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",
36
- self.__class__.__name__,
37
- )
38
-
39
- self._verify_user()
40
-
41
- @property
42
- def token(self) -> str:
43
- return self._token
44
-
45
- @abstractmethod
46
- def _verify_user(self) -> None:
47
- """Verify if user has enough permissions for endpoints in use."""
48
-
49
- async def _post(
50
- self,
51
- endpoint: str,
52
- data: dict[str, Any] | None = None,
53
- ) -> bytes:
54
- """POST request to abs api."""
55
- try:
56
- response = await self.session_config.session.post(
57
- f"{self.session_config.url}/{endpoint}",
58
- json=data,
59
- ssl=self.session_config.verify_ssl,
60
- headers=self.session_config.headers,
61
- raise_for_status=True,
62
- )
63
- except ClientResponseError as exc:
64
- raise ApiError(f"API POST call to {endpoint} failed.") from exc
65
- return await response.read()
66
-
67
- async def _get(self, endpoint: str, params: dict[str, str | int] | None = None) -> bytes:
68
- """GET request to abs api."""
69
- response = await self.session_config.session.get(
70
- f"{self.session_config.url}/{endpoint}",
71
- params=params,
72
- ssl=self.session_config.verify_ssl,
73
- headers=self.session_config.headers,
74
- )
75
- status = response.status
76
- if response.content_type == "application/json" and status == 200:
77
- return await response.read()
78
- if status == 404:
79
- return b""
80
- raise ApiError(f"API GET call to {endpoint} failed.")
81
-
82
- async def _patch(self, endpoint: str, data: dict[str, Any] | None = None) -> None:
83
- """PATCH request to abs api."""
84
- try:
85
- await self.session_config.session.patch(
86
- f"{self.session_config.url}/{endpoint}",
87
- json=data,
88
- ssl=self.session_config.verify_ssl,
89
- headers=self.session_config.headers,
90
- raise_for_status=True,
91
- )
92
- except ClientResponseError as exc:
93
- raise ApiError(f"API PATCH call to {endpoint} failed.") from exc
94
-
95
- async def _delete(self, endpoint: str) -> None:
96
- """DELETE request to abs api."""
97
- try:
98
- await self.session_config.session.delete(
99
- f"{self.session_config.url}/{endpoint}",
100
- ssl=self.session_config.verify_ssl,
101
- headers=self.session_config.headers,
102
- raise_for_status=True,
103
- )
104
- except ClientResponseError as exc:
105
- raise ApiError(f"API DELETE call to {endpoint} failed.") from exc
106
-
107
- async def logout(self) -> None:
108
- """Logout client."""
109
- await self._post("logout")