aioaudiobookshelf 0.1.7__tar.gz → 0.1.8__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.
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/PKG-INFO +1 -1
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/__init__.py +8 -26
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/client/__init__.py +50 -28
- aioaudiobookshelf-0.1.8/aioaudiobookshelf/client/_base.py +197 -0
- aioaudiobookshelf-0.1.8/aioaudiobookshelf/client/session_configuration.py +105 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/exceptions.py +12 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/helpers.py +29 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/calls_login.py +3 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/user.py +7 -1
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf.egg-info/PKG-INFO +1 -1
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf.egg-info/SOURCES.txt +1 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/pyproject.toml +2 -2
- aioaudiobookshelf-0.1.7/aioaudiobookshelf/client/_base.py +0 -109
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/LICENSE +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/README.md +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/client/authors.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/client/collections_.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/client/items.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/client/libraries.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/client/me.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/client/playlists.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/client/podcasts.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/client/series.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/client/session.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/__init__.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/audio.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/author.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/book.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/calls_authors.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/calls_collections.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/calls_items.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/calls_library.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/calls_me.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/calls_playlists.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/calls_series.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/calls_session.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/collection.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/events_socket.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/file.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/folder.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/library.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/media_progress.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/playlist.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/podcast.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/series.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/series_books.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/server.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/session.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/shelf.py +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf.egg-info/dependency_links.txt +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf.egg-info/not-zip-safe +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf.egg-info/requires.txt +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf.egg-info/top_level.txt +0 -0
- {aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/setup.cfg +0 -0
|
@@ -2,29 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from aiohttp.client_exceptions import ClientResponseError, InvalidUrlClientError
|
|
4
4
|
|
|
5
|
-
from aioaudiobookshelf.client import AdminClient,
|
|
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.
|
|
8
|
+
from aioaudiobookshelf.helpers import get_login_response
|
|
9
|
+
from aioaudiobookshelf.schema.calls_login import AuthorizeResponse
|
|
8
10
|
|
|
9
|
-
__version__ = "0.1.
|
|
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.8"
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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__(
|
|
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
|
-
|
|
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())
|
|
@@ -52,7 +52,13 @@ class _UserBase(_BaseModel):
|
|
|
52
52
|
class User(_UserBase):
|
|
53
53
|
"""User."""
|
|
54
54
|
|
|
55
|
-
|
|
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[
|
|
@@ -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.
|
|
21
|
+
version = "0.1.8"
|
|
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.
|
|
136
|
+
current_version = "0.1.8"
|
|
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")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/client/collections_.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/calls_authors.py
RENAMED
|
File without changes
|
{aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/calls_collections.py
RENAMED
|
File without changes
|
|
File without changes
|
{aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/calls_library.py
RENAMED
|
File without changes
|
|
File without changes
|
{aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/calls_playlists.py
RENAMED
|
File without changes
|
{aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/calls_series.py
RENAMED
|
File without changes
|
{aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/calls_session.py
RENAMED
|
File without changes
|
|
File without changes
|
{aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/events_socket.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/media_progress.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf/schema/series_books.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aioaudiobookshelf-0.1.7 → aioaudiobookshelf-0.1.8}/aioaudiobookshelf.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|