aioaudiobookshelf 0.1.4__py3-none-any.whl → 0.1.11__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.

@@ -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.4"
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.11"
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:
@@ -4,11 +4,12 @@ import logging
4
4
  from abc import abstractmethod
5
5
  from typing import TYPE_CHECKING, Any
6
6
 
7
+ from aiohttp.client import ClientResponse
7
8
  from aiohttp.client_exceptions import ClientResponseError
8
9
 
9
10
  if TYPE_CHECKING:
10
- from aioaudiobookshelf.client import SessionConfiguration
11
- from aioaudiobookshelf.exceptions import ApiError
11
+ from aioaudiobookshelf.client.session_configuration import SessionConfiguration
12
+ from aioaudiobookshelf.exceptions import AccessTokenExpiredError, ApiError, TokenIsMissingError
12
13
  from aioaudiobookshelf.schema.calls_login import LoginResponse
13
14
 
14
15
 
@@ -20,9 +21,17 @@ class BaseClient:
20
21
  ) -> None:
21
22
  self.session_config = session_config
22
23
  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
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
26
35
 
27
36
  if self.session_config.logger is None:
28
37
  self.logger = logging.getLogger(__name__)
@@ -32,16 +41,19 @@ class BaseClient:
32
41
  self.logger = self.session_config.logger
33
42
 
34
43
  self.logger.debug(
35
- "Initialized client %s, token: %s",
44
+ "Initialized client %s",
36
45
  self.__class__.__name__,
37
- self._token,
38
46
  )
39
47
 
40
48
  self._verify_user()
41
49
 
42
50
  @property
43
51
  def token(self) -> str:
44
- return self._token
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
45
57
 
46
58
  @abstractmethod
47
59
  def _verify_user(self) -> None:
@@ -53,26 +65,57 @@ class BaseClient:
53
65
  data: dict[str, Any] | None = None,
54
66
  ) -> bytes:
55
67
  """POST request to abs api."""
56
- try:
57
- response = await self.session_config.session.post(
68
+
69
+ async def _request() -> ClientResponse:
70
+ return await self.session_config.session.post(
58
71
  f"{self.session_config.url}/{endpoint}",
59
72
  json=data,
60
73
  ssl=self.session_config.verify_ssl,
61
74
  headers=self.session_config.headers,
62
75
  raise_for_status=True,
76
+ timeout=self.session_config.timeout,
63
77
  )
78
+
79
+ try:
80
+ response = await _request()
64
81
  except ClientResponseError as exc:
65
- raise ApiError(f"API POST call to {endpoint} failed.") from exc
82
+ if exc.code == 401:
83
+ if self.session_config.auto_refresh:
84
+ self.logger.debug("Auto refreshing tokens.")
85
+ await self.refresh()
86
+ # TODO: remove redundant clause
87
+ try:
88
+ response = await _request()
89
+ except ClientResponseError as inner_exc:
90
+ raise ApiError(f"API POST call to {endpoint} failed.") from inner_exc
91
+ else:
92
+ raise AccessTokenExpiredError from exc
93
+ else:
94
+ raise ApiError(f"API POST call to {endpoint} failed.") from exc
95
+
66
96
  return await response.read()
67
97
 
68
98
  async def _get(self, endpoint: str, params: dict[str, str | int] | None = None) -> bytes:
69
99
  """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
- )
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
+ timeout=self.session_config.timeout,
108
+ )
109
+
110
+ response = await _request()
111
+ if response.status == 401:
112
+ if self.session_config.auto_refresh:
113
+ self.logger.debug("Auto refreshing tokens.")
114
+ await self.refresh()
115
+ response = await _request()
116
+ else:
117
+ raise AccessTokenExpiredError
118
+
76
119
  status = response.status
77
120
  if response.content_type == "application/json" and status == 200:
78
121
  return await response.read()
@@ -82,29 +125,74 @@ class BaseClient:
82
125
 
83
126
  async def _patch(self, endpoint: str, data: dict[str, Any] | None = None) -> None:
84
127
  """PATCH request to abs api."""
85
- try:
128
+
129
+ async def _request() -> None:
86
130
  await self.session_config.session.patch(
87
131
  f"{self.session_config.url}/{endpoint}",
88
132
  json=data,
89
133
  ssl=self.session_config.verify_ssl,
90
134
  headers=self.session_config.headers,
91
135
  raise_for_status=True,
136
+ timeout=self.session_config.timeout,
92
137
  )
138
+
139
+ try:
140
+ await _request()
93
141
  except ClientResponseError as exc:
94
- raise ApiError(f"API PATCH call to {endpoint} failed.") from exc
142
+ if exc.code == 401:
143
+ if self.session_config.auto_refresh:
144
+ self.logger.debug("Auto refreshing tokens.")
145
+ await self.refresh()
146
+ try:
147
+ await _request()
148
+ except ClientResponseError as inner_exc:
149
+ raise ApiError(f"API PATCH call to {endpoint} failed.") from inner_exc
150
+ else:
151
+ raise AccessTokenExpiredError from exc
152
+ else:
153
+ raise ApiError(f"API PATCH call to {endpoint} failed.") from exc
95
154
 
96
155
  async def _delete(self, endpoint: str) -> None:
97
156
  """DELETE request to abs api."""
98
- try:
157
+
158
+ async def _request() -> None:
99
159
  await self.session_config.session.delete(
100
160
  f"{self.session_config.url}/{endpoint}",
101
161
  ssl=self.session_config.verify_ssl,
102
162
  headers=self.session_config.headers,
103
163
  raise_for_status=True,
164
+ timeout=self.session_config.timeout,
104
165
  )
166
+
167
+ try:
168
+ await _request()
105
169
  except ClientResponseError as exc:
106
- raise ApiError(f"API DELETE call to {endpoint} failed.") from exc
170
+ if exc.code == 401:
171
+ if self.session_config.auto_refresh:
172
+ self.logger.debug("Auto refreshing tokens.")
173
+ await self.refresh()
174
+ try:
175
+ await _request()
176
+ except ClientResponseError as inner_exc:
177
+ raise ApiError(f"API DELETE call to {endpoint} failed.") from inner_exc
178
+ else:
179
+ raise AccessTokenExpiredError from exc
180
+ else:
181
+ raise ApiError(f"API DELETE call to {endpoint} failed.") from exc
182
+
183
+ async def refresh(self) -> None:
184
+ """Refresh tokens."""
185
+ await self.session_config.refresh()
107
186
 
108
187
  async def logout(self) -> None:
109
188
  """Logout client."""
110
- await self._post("logout")
189
+ if self.session_config.refresh_token is not None:
190
+ # v2.26 and above
191
+ await self.session_config.session.post(
192
+ f"{self.session_config.url}/logout",
193
+ ssl=self.session_config.verify_ssl,
194
+ headers=self.session_config.headers_refresh_logout,
195
+ raise_for_status=True,
196
+ )
197
+ else:
198
+ await self._post("logout")
@@ -1,10 +1,19 @@
1
1
  """Calls to /api/authors."""
2
2
 
3
+ from enum import StrEnum
4
+
3
5
  from aioaudiobookshelf.client._base import BaseClient
4
6
  from aioaudiobookshelf.schema.author import Author
5
7
  from aioaudiobookshelf.schema.calls_authors import AuthorWithItems, AuthorWithItemsAndSeries
6
8
 
7
9
 
10
+ class AuthorImageFormat(StrEnum):
11
+ """AuthorImageFormat."""
12
+
13
+ JPEG = "jpeg"
14
+ WEBP = "webp"
15
+
16
+
8
17
  class AuthorsClient(BaseClient):
9
18
  """AuthorsClient."""
10
19
 
@@ -31,4 +40,18 @@ class AuthorsClient(BaseClient):
31
40
 
32
41
  # update author
33
42
  # match author
34
- # get author image
43
+ async def get_author_image(
44
+ self,
45
+ *,
46
+ author_id: str,
47
+ width: int = 400,
48
+ height: int | None = None,
49
+ format_: AuthorImageFormat = AuthorImageFormat.JPEG,
50
+ raw: bool = False,
51
+ ) -> bytes:
52
+ """Get image of author. If height is None, image is scaled proportionally."""
53
+ endpoint = f"/api/authors/{author_id}/image"
54
+ params = {"width": width, "format": format_.value, "raw": int(raw)}
55
+ if height:
56
+ params["height"] = height
57
+ return await self._get(endpoint, params=params)
@@ -3,6 +3,7 @@
3
3
  from collections.abc import AsyncGenerator
4
4
  from typing import TypeVar
5
5
 
6
+ from mashumaro.codecs.json import json_decode
6
7
  from mashumaro.mixins.json import DataClassJSONMixin
7
8
 
8
9
  from aioaudiobookshelf.client._base import BaseClient
@@ -18,6 +19,14 @@ from aioaudiobookshelf.schema.calls_library import (
18
19
  LibraryWithFilterDataResponse,
19
20
  )
20
21
  from aioaudiobookshelf.schema.library import Library, LibraryFilterData
22
+ from aioaudiobookshelf.schema.shelf import (
23
+ Shelf,
24
+ ShelfAuthors,
25
+ ShelfBook,
26
+ ShelfEpisode,
27
+ ShelfPodcast,
28
+ ShelfSeries,
29
+ )
21
30
 
22
31
  ResponseMinified = TypeVar("ResponseMinified", bound=DataClassJSONMixin)
23
32
  ResponseNormal = TypeVar("ResponseNormal", bound=DataClassJSONMixin)
@@ -147,7 +156,17 @@ class LibrariesClient(BaseClient):
147
156
  ):
148
157
  yield result
149
158
 
150
- # get library's personalized view
159
+ async def get_library_personalized_view(
160
+ self, *, library_id: str, limit: int = 10
161
+ ) -> list[ShelfBook | ShelfPodcast | ShelfAuthors | ShelfEpisode | ShelfSeries]:
162
+ """Get personalized view of library.
163
+
164
+ TODO: Add rssfeed
165
+ """
166
+ response = await self._get(
167
+ endpoint=f"/api/libraries/{library_id}/personalized", params={"limit": limit}
168
+ )
169
+ return json_decode(response, list[Shelf])
151
170
 
152
171
  async def get_library_filterdata(self, *, library_id: str) -> LibraryFilterData:
153
172
  """Get filterdata of library."""
@@ -1,7 +1,10 @@
1
1
  """Calls to /api/session."""
2
2
 
3
3
  from aioaudiobookshelf.client._base import BaseClient
4
- from aioaudiobookshelf.schema.calls_session import CloseOpenSessionsParameters
4
+ from aioaudiobookshelf.schema.calls_session import (
5
+ CloseOpenSessionsParameters,
6
+ SyncOpenSessionParameters,
7
+ )
5
8
  from aioaudiobookshelf.schema.session import PlaybackSessionExpanded
6
9
 
7
10
 
@@ -24,7 +27,11 @@ class SessionClient(BaseClient):
24
27
  )
25
28
  return psession
26
29
 
27
- # sync open session
30
+ async def sync_open_session(
31
+ self, *, session_id: str, parameters: SyncOpenSessionParameters
32
+ ) -> None:
33
+ """Sync an open session."""
34
+ await self._post(f"/api/session/{session_id}/sync", data=parameters.to_dict())
28
35
 
29
36
  async def close_open_session(
30
37
  self, *, session_id: str, parameters: CloseOpenSessionsParameters | None = None
@@ -0,0 +1,106 @@
1
+ """Session Configuration."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from dataclasses import dataclass
6
+
7
+ from aiohttp.client import DEFAULT_TIMEOUT, ClientSession, ClientTimeout
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
+ timeout: ClientTimeout = DEFAULT_TIMEOUT
36
+ logger: logging.Logger | None = None
37
+
38
+ @property
39
+ def headers(self) -> dict[str, str]:
40
+ """Session headers.
41
+
42
+ These are normal request headers.
43
+ """
44
+ if self.token is not None:
45
+ return {"Authorization": f"Bearer {self.token}"}
46
+ if self.access_token is not None:
47
+ return {"Authorization": f"Bearer {self.access_token}"}
48
+ raise TokenIsMissingError("Token not set.")
49
+
50
+ @property
51
+ def headers_refresh_logout(self) -> dict[str, str]:
52
+ """Session headers for /auth/refresh and /logout.
53
+
54
+ Only v2.26 and above.
55
+ """
56
+ if self.refresh_token is None:
57
+ raise TokenIsMissingError("Refresh token not set.")
58
+ return {"x-refresh-token": self.refresh_token}
59
+
60
+ def __post_init__(self) -> None:
61
+ """Post init."""
62
+ self.url = self.url.rstrip("/")
63
+ self.__refresh_lock = asyncio.Lock()
64
+
65
+ async def refresh(self) -> None:
66
+ """Refresh access_token with refresh token.
67
+
68
+ v2.26 and above
69
+ """
70
+ if self.__refresh_lock.locked():
71
+ return
72
+ async with self.__refresh_lock:
73
+ try:
74
+ endpoint = "auth/refresh"
75
+ response = await self.session.post(
76
+ f"{self.url}/{endpoint}",
77
+ ssl=self.verify_ssl,
78
+ headers=self.headers_refresh_logout,
79
+ raise_for_status=True,
80
+ )
81
+ except ClientResponseError as err:
82
+ if err.code == 503:
83
+ raise ServiceUnavailableError from err
84
+ raise RefreshTokenExpiredError from err
85
+ data = await response.read()
86
+ refresh_response = RefreshResponse.from_json(data)
87
+ assert refresh_response.user.access_token is not None
88
+ assert refresh_response.user.refresh_token is not None
89
+ self.access_token = refresh_response.user.access_token
90
+ self.refresh_token = refresh_response.user.refresh_token
91
+
92
+ async def authenticate(self, *, username: str, password: str) -> None:
93
+ """Relogin and update tokens if refresh token expired."""
94
+ async with self.__refresh_lock:
95
+ login_response = await get_login_response(
96
+ session_config=self, username=username, password=password
97
+ )
98
+ if login_response.user.access_token is None:
99
+ # pre v2.26
100
+ assert login_response.user.token is not None
101
+ self.token = login_response.user.token
102
+ return
103
+ assert login_response.user.access_token is not None
104
+ assert login_response.user.refresh_token is not None
105
+ self.access_token = login_response.user.access_token
106
+ 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())
@@ -56,7 +56,7 @@ class AudioFile(_BaseModel):
56
56
  exclude: bool
57
57
  error: str | None = None
58
58
  format: str
59
- duration: float
59
+ duration: float | None
60
60
  bit_rate: Annotated[int, Alias("bitRate")]
61
61
  language: str | None = None
62
62
  codec: str
@@ -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
@@ -15,3 +15,6 @@ class CloseOpenSessionsParameters(_BaseModel):
15
15
  current_time: Annotated[float, Alias("currentTime")]
16
16
  time_listened: Annotated[float, Alias("timeListened")]
17
17
  duration: float
18
+
19
+
20
+ SyncOpenSessionParameters = CloseOpenSessionsParameters
@@ -17,6 +17,7 @@ class FileMetadata(_BaseModel):
17
17
  path: str
18
18
  relative_path: Annotated[str, Alias("relPath")]
19
19
  size: int # in bytes
20
- modified_time_ms: Annotated[int, Alias("mtimeMs")]
20
+ # might not be present, see https://github.com/music-assistant/support/issues/3914
21
+ modified_time_ms: Annotated[int | None, Alias("mtimeMs")] = None
21
22
  changed_time_ms: Annotated[int, Alias("ctimeMs")]
22
23
  created_time_ms: Annotated[int, Alias("birthtimeMs")] = 0 # 0 if unknown
@@ -0,0 +1,188 @@
1
+ """Schema for shelf, the response object to library's personalized view."""
2
+ # Discriminators don't work together with aliases.
3
+ # https://github.com/Fatal1ty/mashumaro/issues/254
4
+ # ruff: noqa: N815
5
+
6
+ from dataclasses import dataclass
7
+ from enum import StrEnum
8
+ from typing import Annotated
9
+
10
+ from mashumaro.types import Alias, Discriminator
11
+
12
+ from aioaudiobookshelf.schema.author import AuthorExpanded
13
+ from aioaudiobookshelf.schema.book import BookMinified
14
+
15
+ from . import _BaseModel
16
+ from .library import LibraryMediaType, _LibraryItemBase
17
+ from .podcast import PodcastEpisode, PodcastMinified
18
+ from .series import Series
19
+
20
+
21
+ class ShelfId(StrEnum):
22
+ """ShelfId."""
23
+
24
+ LISTEN_AGAIN = "listen-again"
25
+ CONTINUE_LISTENING = "continue-listening"
26
+ CONTINUE_SERIES = "continue-series"
27
+ RECOMMENDED = "recommended"
28
+ RECENTLY_ADDED = "recently-added"
29
+ EPISODES_RECENTLY_ADDED = "episodes-recently-added"
30
+ RECENT_SERIES = "recent-series"
31
+ NEWEST_AUTHORS = "newest-authors"
32
+ NEWEST_EPISODES = "newest-episodes"
33
+ DISCOVER = "discover"
34
+
35
+
36
+ @dataclass(kw_only=True)
37
+ class ShelfLibraryItemMinified(_LibraryItemBase):
38
+ """ShelfLibraryItemMinified.
39
+
40
+ Beside using type, there is another distinction on which attributes
41
+ are available based on the id. We allow ourselves the easy route
42
+ here, and just make all of them optional.
43
+ """
44
+
45
+ # episode (type) and
46
+ # id: continue-listening, listen-again, episodes-recently-added
47
+ recent_episode: Annotated[PodcastEpisode | None, Alias("recentEpisode")] = None
48
+
49
+ # id: continue-listening
50
+ progress_last_update_ms: Annotated[int | None, Alias("progressLastUpdate")] = None
51
+
52
+ # id: continue-series
53
+ previous_book_in_progress_last_update_ms: Annotated[
54
+ int | None, Alias("prevBookInProgressLastUpdate")
55
+ ] = None
56
+ # TODO: minified item has seriesSequence, which we currently ignore
57
+
58
+ # id: recommended
59
+ weight: float | None = None
60
+
61
+ # id: listen-again
62
+ finished_at_ms: Annotated[int | None, Alias("finishedAt")] = None
63
+
64
+ # This and the two subclasses are copied over from libraries, as we otherwise
65
+ # face issues with the discriminator
66
+ class Config(_LibraryItemBase.Config):
67
+ """Config."""
68
+
69
+ discriminator = Discriminator(
70
+ field="mediaType",
71
+ include_subtypes=True,
72
+ )
73
+
74
+ num_files: Annotated[int, Alias("numFiles")]
75
+ size: int
76
+
77
+
78
+ @dataclass(kw_only=True)
79
+ class LibraryItemMinifiedBook(ShelfLibraryItemMinified):
80
+ """LibraryItemMinifiedBook."""
81
+
82
+ media: BookMinified
83
+ mediaType: LibraryMediaType = LibraryMediaType.BOOK
84
+
85
+
86
+ @dataclass(kw_only=True)
87
+ class LibraryItemMinifiedPodcast(ShelfLibraryItemMinified):
88
+ """LibraryItemMinifiedPodcast."""
89
+
90
+ media: PodcastMinified
91
+ mediaType: LibraryMediaType = LibraryMediaType.PODCAST
92
+
93
+
94
+ @dataclass(kw_only=True)
95
+ class LibraryItemMinifiedBookSeriesShelf(ShelfLibraryItemMinified):
96
+ """LibraryItemMinifiedBookSeriesShelf."""
97
+
98
+ # this appears not to be around?
99
+ series_sequence: Annotated[str | int | None, Alias("seriesSequence")] = None
100
+
101
+
102
+ @dataclass(kw_only=True)
103
+ class SeriesShelf(Series):
104
+ """SeriesShelf."""
105
+
106
+ books: list[LibraryItemMinifiedBookSeriesShelf]
107
+ in_progress: Annotated[bool | None, Alias("inProgress")] = None
108
+ has_active_book: Annotated[bool | None, Alias("hasActiveBook")] = None
109
+ hide_from_continue_listening: Annotated[bool | None, Alias("hideFromContinueListening")] = None
110
+ book_in_progress_last_update_ms: Annotated[int | None, Alias("bookInProgressLastUpdate")] = None
111
+ first_book_unread: Annotated[
112
+ LibraryItemMinifiedBookSeriesShelf | None, Alias("firstBookUnread")
113
+ ] = None
114
+
115
+
116
+ class ShelfType(StrEnum):
117
+ """ShelfType."""
118
+
119
+ BOOK = "book"
120
+ SERIES = "series"
121
+ AUTHORS = "authors"
122
+ EPISODE = "episode"
123
+ PODCAST = "podcast"
124
+
125
+
126
+ @dataclass(kw_only=True)
127
+ class _ShelfBase(_BaseModel):
128
+ """Shelf."""
129
+
130
+ id_: Annotated[ShelfId | str, Alias("id")]
131
+ label: str
132
+ label_string_key: Annotated[str, Alias("labelStringKey")]
133
+ type_: Annotated[ShelfType, Alias("type")]
134
+ category: str | None = None
135
+
136
+
137
+ @dataclass(kw_only=True)
138
+ class Shelf(_ShelfBase):
139
+ """Shelf."""
140
+
141
+ class Config(_ShelfBase.Config):
142
+ """Config."""
143
+
144
+ discriminator = Discriminator(
145
+ field="type",
146
+ include_subtypes=True,
147
+ )
148
+
149
+
150
+ @dataclass(kw_only=True)
151
+ class ShelfBook(Shelf):
152
+ """ShelfBook."""
153
+
154
+ type = ShelfType.BOOK
155
+ entities: list[ShelfLibraryItemMinified]
156
+
157
+
158
+ @dataclass(kw_only=True)
159
+ class ShelfPodcast(Shelf):
160
+ """ShelfBook."""
161
+
162
+ type = ShelfType.PODCAST
163
+ entities: list[ShelfLibraryItemMinified]
164
+
165
+
166
+ @dataclass(kw_only=True)
167
+ class ShelfEpisode(Shelf):
168
+ """ShelfBook."""
169
+
170
+ type = ShelfType.EPISODE
171
+ entities: list[ShelfLibraryItemMinified]
172
+
173
+
174
+ @dataclass(kw_only=True)
175
+ class ShelfAuthors(Shelf):
176
+ """ShelfAuthor."""
177
+
178
+ type = ShelfType.AUTHORS
179
+
180
+ entities: list[AuthorExpanded]
181
+
182
+
183
+ @dataclass(kw_only=True)
184
+ class ShelfSeries(Shelf):
185
+ """ShelfSeries."""
186
+
187
+ type = ShelfType.SERIES
188
+ entities: list[SeriesShelf]
@@ -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
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: aioaudiobookshelf
3
- Version: 0.1.4
3
+ Version: 0.1.11
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
@@ -27,6 +27,7 @@ Requires-Dist: syrupy==4.8.1; extra == "test"
27
27
  Requires-Dist: tomli==2.2.1; extra == "test"
28
28
  Requires-Dist: ruff==0.9.2; extra == "test"
29
29
  Requires-Dist: bumpver; extra == "test"
30
+ Dynamic: license-file
30
31
 
31
32
  # aioaudiobookshelf
32
33
  Async python library to interact with
@@ -109,7 +110,7 @@ async def abs_basics():
109
110
 
110
111
  # get a single podcast
111
112
  podcast_id = "dda96167-eaad-4012-83e1-149c6700d3e8"
112
- podcast_expanded = client.get_library_item_podcast(podcast_id=podcast_id, expanded=True)
113
+ podcast_expanded = await client.get_library_item_podcast(podcast_id=podcast_id, expanded=True)
113
114
 
114
115
 
115
116
  asyncio.run(abs_basics())
@@ -1,33 +1,34 @@
1
- aioaudiobookshelf/__init__.py,sha256=zihAWwXc_wjsyXfWPt8TJ4r3Ichm5so7484NsDXThP0,4138
2
- aioaudiobookshelf/exceptions.py,sha256=JQNdqBaR5AhuCUBg5DH5-orkfJrx_CJpe53ignWD6vE,377
3
- aioaudiobookshelf/helpers.py,sha256=ImZntca2l39P285TVVrFah82MjrP4wXLnVd7LMoD5Ck,1581
4
- aioaudiobookshelf/client/__init__.py,sha256=YnrNm2ZmQeVqGQ8EfAKIPRI4p2cLFqCzv4uKgL6HJPM,6931
5
- aioaudiobookshelf/client/_base.py,sha256=1S0ACN-fiXKy0uVVLb422LGFB4bOvVFnSvyEDM47-v0,3815
6
- aioaudiobookshelf/client/authors.py,sha256=8bXN0NsPvH1rvF166xUu7rVC-0P0Sp4N7oxZrO666nc,1129
1
+ aioaudiobookshelf/__init__.py,sha256=xebDvWxDTQNfKKQi9I9V7yKxke3WZoBks6e2nYDPle8,3508
2
+ aioaudiobookshelf/exceptions.py,sha256=G3z99KrxHoSoj7kpSRVpa7Rp_RkL3jmW6266ak8h_p0,661
3
+ aioaudiobookshelf/helpers.py,sha256=gvoBWsu6PSiGYyXe5BH9TP6WydzAMp_6Ye9RUivvk9Y,2700
4
+ aioaudiobookshelf/client/__init__.py,sha256=6xwDrY0xWWC8cXHn5GK3Mc1jPpmmtfJ9HGO9aryKL8U,7993
5
+ aioaudiobookshelf/client/_base.py,sha256=J_RjItJFaq17Po66aEzwrpq6PiCns3xYXWAtewy48K0,7584
6
+ aioaudiobookshelf/client/authors.py,sha256=9F8_tiTqh-5Srn_5I0wdEa1qs_495UctzSF6OlxFOJw,1808
7
7
  aioaudiobookshelf/client/collections_.py,sha256=Z3r7dxHhzMm_cGCMRJkVzN5-Eesftib3d0bs4iXmmzY,879
8
8
  aioaudiobookshelf/client/items.py,sha256=4yWy7Shd6sbKj4MSGYJXklXs-yZH2q95qGIm4L-PS5o,3624
9
- aioaudiobookshelf/client/libraries.py,sha256=ejzxUCFgCfWtJQMCjAlo7LHWLk82Kl2BdkjpRVKtBck,6250
9
+ aioaudiobookshelf/client/libraries.py,sha256=VK1VqX9V6_8PAhPVx-eIPOgsS8t_U1a6iFML2nOyi14,6852
10
10
  aioaudiobookshelf/client/me.py,sha256=cYmXNoFJHZgEI6Ds19Pf6gNQMBVOqrtcKmiyXHFFIL0,3586
11
11
  aioaudiobookshelf/client/playlists.py,sha256=sTsmuBqjSlKyTo31mc9Ha-WtzX7bh0hH7n1BcnlO7YY,875
12
12
  aioaudiobookshelf/client/podcasts.py,sha256=f1ECD43cn4EIwWpRvwM_RosfzDr8popFDFbRYgCZQxs,684
13
13
  aioaudiobookshelf/client/series.py,sha256=bS4RO8k1Im5wpeKt0oNcBSeeZCRIGCi9otTCEOy7xMo,820
14
- aioaudiobookshelf/client/session.py,sha256=YWv4GkrW5fMbq1AdnMOWQHM_YzAH1U6varzsyXlP0Jc,1249
14
+ aioaudiobookshelf/client/session.py,sha256=1eMI_caj6dSTtyC3aqKgLCDqWLEOodFtBkEGoqTOmnw,1508
15
+ aioaudiobookshelf/client/session_configuration.py,sha256=_Y-uDA9iW87siiN9xe-pCOxMS0V41Fl6MGWY4EuZ1tA,3958
15
16
  aioaudiobookshelf/schema/__init__.py,sha256=vEEwPVweT0j2VqlBj2V34Xk5J9OBZ7zkd6eLOUx-RdU,358
16
- aioaudiobookshelf/schema/audio.py,sha256=ByDu3pvldxsXkXP3WqVlxW-7m8NOH9TY-AQ6HfE5wxc,2098
17
+ aioaudiobookshelf/schema/audio.py,sha256=PFKkGsZgJSDYJS-7VwoTvYTcVfR8ip8qOuE8hQa9TtE,2105
17
18
  aioaudiobookshelf/schema/author.py,sha256=OZGcCUHOfXvN8N2SRUiIRnDS2Lz3UrQFWOaOZ1sKV0U,976
18
- aioaudiobookshelf/schema/book.py,sha256=Ckfc_eC6jXtQnQQzl0QaB0edp91G8gIiUJTE7IfctuM,3372
19
+ aioaudiobookshelf/schema/book.py,sha256=mf_PZqClvbfoxUQtm88Cqz14xT-594NZPi6nkQe1_Hk,3386
19
20
  aioaudiobookshelf/schema/calls_authors.py,sha256=eIvdyEcrmXuOxFJuCX39cGU9HSZprl40yKn8s2VjfAM,703
20
21
  aioaudiobookshelf/schema/calls_collections.py,sha256=nXurha41Y1atAuOWG9aXJbYo66qgcFUaW02QPEVdo4w,319
21
22
  aioaudiobookshelf/schema/calls_items.py,sha256=KF8s6WNbsVUIxAuN9TZBBGsysnxfw-FQWM9XRTljOpo,1547
22
23
  aioaudiobookshelf/schema/calls_library.py,sha256=IX4vf55fUW5wLADo1QnCIg3tEhtQ2UuyKsZWpx5CmtU,2543
23
- aioaudiobookshelf/schema/calls_login.py,sha256=squLCKxXHRxzlm4-1BOCDQ5-Zics1717JuZ89STkN5g,791
24
+ aioaudiobookshelf/schema/calls_login.py,sha256=DpIBtAzg5uQtS0CLDfEXHkOh_RGfDI5epFVnziFsEEs,853
24
25
  aioaudiobookshelf/schema/calls_me.py,sha256=jdpvExRytoqX8FHkzq4N9RtKn50cmTc_B693e9lLCHQ,693
25
26
  aioaudiobookshelf/schema/calls_playlists.py,sha256=Ll1scstd1NNc6PXgkhmWUfqSzIR07LKzqBCEvrVHACE,305
26
27
  aioaudiobookshelf/schema/calls_series.py,sha256=aUdlTR01UE2APN0pGS1h7x0ydsREqZf53mKaHq2zeh4,597
27
- aioaudiobookshelf/schema/calls_session.py,sha256=j2NLUwS4820SwzqPXksteTACXtUXvNtio_wBfvwHHM0,416
28
+ aioaudiobookshelf/schema/calls_session.py,sha256=E90uPxkZrV1FUTSYS9owbzTm8HaRjH4CRmb88XrGqOA,474
28
29
  aioaudiobookshelf/schema/collection.py,sha256=fB1VFQXE0hzM_NyWEHj9A11VztD_8rYo4t9OWkDj-Lc,883
29
30
  aioaudiobookshelf/schema/events_socket.py,sha256=r24ssixH0dWfyOhv0K5P6GgzGrbYC9Yh8Y2w76-bkeM,1420
30
- aioaudiobookshelf/schema/file.py,sha256=y_X-m_mk41OUcLpWQ5U_xndyIlG0BoNLS5iJL1RzWqQ,538
31
+ aioaudiobookshelf/schema/file.py,sha256=frymCwsiqOh6fnZPBnkUz1pfyUuKkH4ssRd2WHpsajk,639
31
32
  aioaudiobookshelf/schema/folder.py,sha256=3S6aA306JHhOGtCnawUwMJv13_AXsHmhmNX5RzbjnqY,414
32
33
  aioaudiobookshelf/schema/library.py,sha256=6Gf9DVjKPJhbmKBaXXSbeUddN-VeUx7mIHZrx7HrfMQ,6174
33
34
  aioaudiobookshelf/schema/media_progress.py,sha256=nIISrNvkFV0zmxkGTePuGaw9xW21lTo7ceDdLfG2ffQ,1390
@@ -37,9 +38,10 @@ aioaudiobookshelf/schema/series.py,sha256=XmwDvMchb-W2y-Tm-eKiE-3rPjs4kXC3Ib2dZO
37
38
  aioaudiobookshelf/schema/series_books.py,sha256=rs8a4JmSHqJR6WPA2XKXmC6XDq8D9wmzxftLlPhvub8,868
38
39
  aioaudiobookshelf/schema/server.py,sha256=yWBRxtwtX1USs43yGFuDIM3jfWbmduW3GRahisOUCqw,2726
39
40
  aioaudiobookshelf/schema/session.py,sha256=jqCHNUthuzE6jhgG3UwFgagl1HA_rfDwn6Te38jaqC8,2684
40
- aioaudiobookshelf/schema/user.py,sha256=Zcnl6gqfc97dmHrNOHVsX_OOqwcJq-eFMhc9zVMLIs4,2057
41
- aioaudiobookshelf-0.1.4.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
42
- aioaudiobookshelf-0.1.4.dist-info/METADATA,sha256=OmHbBKE2vAJ8-wU1v1NKsrnTEfwjz5-MTqRnQNKJt6o,4377
43
- aioaudiobookshelf-0.1.4.dist-info/WHEEL,sha256=jB7zZ3N9hIM9adW7qlTAyycLYW9npaWKLRzaoVcLKcM,91
44
- aioaudiobookshelf-0.1.4.dist-info/top_level.txt,sha256=2_I2_xz98xmVIT84pcF3tlq3NdZNKskfs7BqUmYZylk,18
45
- aioaudiobookshelf-0.1.4.dist-info/RECORD,,
41
+ aioaudiobookshelf/schema/shelf.py,sha256=npPr5iacm6b5FfJYF9_lFZaitJNJ3SVvBOScbxlF5MQ,5064
42
+ aioaudiobookshelf/schema/user.py,sha256=cm2Oev9i062mjM3Ku-yaAjm_o4-sJOkJTczdq43_qrY,2485
43
+ aioaudiobookshelf-0.1.11.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
44
+ aioaudiobookshelf-0.1.11.dist-info/METADATA,sha256=uMPo-whmeNF-pi58S6OOA-7vejepwzgNS4lcTfodzvM,4406
45
+ aioaudiobookshelf-0.1.11.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
46
+ aioaudiobookshelf-0.1.11.dist-info/top_level.txt,sha256=2_I2_xz98xmVIT84pcF3tlq3NdZNKskfs7BqUmYZylk,18
47
+ aioaudiobookshelf-0.1.11.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.8.2)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5