lexicon-python 0.4.0__py3-none-any.whl → 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lexicon/__init__.py +16 -1
- lexicon/client.py +303 -9
- lexicon/resources/__init__.py +2 -0
- lexicon/resources/control_types.py +450 -0
- lexicon/resources/player.py +100 -0
- lexicon/resources/player_types.py +49 -0
- lexicon/resources/playlists.py +35 -2
- lexicon/resources/playlists_types.py +2 -0
- lexicon/resources/tag_categories.py +41 -12
- lexicon/resources/tags_types.py +2 -0
- lexicon/resources/tracks.py +223 -35
- lexicon/resources/tracks_types.py +145 -38
- {lexicon_python-0.4.0.dist-info → lexicon_python-0.5.0.dist-info}/METADATA +148 -12
- lexicon_python-0.5.0.dist-info/RECORD +29 -0
- {lexicon_python-0.4.0.dist-info → lexicon_python-0.5.0.dist-info}/WHEEL +1 -1
- lexicon_python-0.4.0.dist-info/RECORD +0 -26
- {lexicon_python-0.4.0.dist-info → lexicon_python-0.5.0.dist-info}/licenses/LICENSE +0 -0
- {lexicon_python-0.4.0.dist-info → lexicon_python-0.5.0.dist-info}/top_level.txt +0 -0
lexicon/__init__.py
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
"""Public package surface for the lexicon Python client."""
|
|
2
2
|
|
|
3
|
-
from .client import
|
|
3
|
+
from .client import (
|
|
4
|
+
DEFAULT_APP_NAME,
|
|
5
|
+
DEFAULT_HOST,
|
|
6
|
+
LEXICON_PORT,
|
|
7
|
+
Lexicon,
|
|
8
|
+
LexiconAuthError,
|
|
9
|
+
LexiconConnectionError,
|
|
10
|
+
)
|
|
4
11
|
from .colors import Color, color_rgb
|
|
12
|
+
from .resources.control_types import CONTROL_ACTIONS, ControlAction
|
|
13
|
+
from .resources.player_types import PlayingResponse, RemoteTrackResponse
|
|
5
14
|
from .resources.playlists_types import PlaylistResponse
|
|
6
15
|
from .resources.tag_categories_types import TagCategoryResponse
|
|
7
16
|
from .resources.tags_types import TagResponse
|
|
@@ -18,11 +27,17 @@ from .resources.tracks_types import (
|
|
|
18
27
|
__all__ = [
|
|
19
28
|
"Color",
|
|
20
29
|
"color_rgb",
|
|
30
|
+
"DEFAULT_APP_NAME",
|
|
21
31
|
"DEFAULT_HOST",
|
|
22
32
|
"LEXICON_PORT",
|
|
23
33
|
"Lexicon",
|
|
34
|
+
"LexiconAuthError",
|
|
24
35
|
"LexiconConnectionError",
|
|
36
|
+
"ControlAction",
|
|
37
|
+
"CONTROL_ACTIONS",
|
|
25
38
|
"TrackResponse",
|
|
39
|
+
"PlayingResponse",
|
|
40
|
+
"RemoteTrackResponse",
|
|
26
41
|
"PlaylistResponse",
|
|
27
42
|
"TagResponse",
|
|
28
43
|
"TagCategoryResponse",
|
lexicon/client.py
CHANGED
|
@@ -8,6 +8,9 @@ from typing import Any, Optional
|
|
|
8
8
|
|
|
9
9
|
import requests
|
|
10
10
|
|
|
11
|
+
from .resources._common_types import ValidationMode
|
|
12
|
+
from .resources.control_types import CONTROL_ACTIONS, ControlAction
|
|
13
|
+
from .resources.player import Player
|
|
11
14
|
from .resources.playlist_tracks import PlaylistTracks
|
|
12
15
|
from .resources.playlists import Playlists
|
|
13
16
|
from .resources.tag_categories import TagCategories
|
|
@@ -18,12 +21,78 @@ from .tools import tracks as track_tools
|
|
|
18
21
|
|
|
19
22
|
DEFAULT_HOST = os.environ.get("LEXICON_HOST", "localhost")
|
|
20
23
|
LEXICON_PORT = int(os.environ.get("LEXICON_PORT", "48624"))
|
|
24
|
+
DEFAULT_APP_NAME = os.environ.get("LEXICON_APP_NAME", "lexicon-python")
|
|
25
|
+
|
|
26
|
+
# Starting in Lexicon v1.12, a request from an app the user has not answered for
|
|
27
|
+
# brings up a popup and waits up to a minute for the answer, so the connection
|
|
28
|
+
# probe has to outlast that wait.
|
|
29
|
+
APPROVAL_TIMEOUT = 70
|
|
30
|
+
# POST /v1/auth/pair waits up to two minutes for the user.
|
|
31
|
+
PAIR_TIMEOUT = 130
|
|
32
|
+
|
|
33
|
+
# Error codes Lexicon v1.12 uses to refuse an app, with what the caller can do.
|
|
34
|
+
_AUTH_ERRORS: dict[int, str] = {
|
|
35
|
+
6: (
|
|
36
|
+
"The user blocked app {app!r} in Lexicon. Unblock it in the Lexicon "
|
|
37
|
+
"settings under Local API."
|
|
38
|
+
),
|
|
39
|
+
9: (
|
|
40
|
+
"Lexicon refused the token, or the request did not name its app. Check the "
|
|
41
|
+
"token (token= or LEXICON_TOKEN); a wrong token counts as no token."
|
|
42
|
+
),
|
|
43
|
+
10: (
|
|
44
|
+
"App {app!r} needs approval. Lexicon showed a popup and got no answer in "
|
|
45
|
+
"time, or it was closed without choosing. Allow the app in Lexicon and try "
|
|
46
|
+
"again; Lexicon waits a minute before asking about the same app again."
|
|
47
|
+
),
|
|
48
|
+
11: (
|
|
49
|
+
"App {app!r} is already allowed, and this request did not send its token. "
|
|
50
|
+
"Pass the token (token= or LEXICON_TOKEN), or remove the app in the Lexicon "
|
|
51
|
+
"settings under Local API so it can ask again."
|
|
52
|
+
),
|
|
53
|
+
}
|
|
21
54
|
|
|
22
55
|
|
|
23
56
|
class LexiconConnectionError(ConnectionError):
|
|
24
57
|
"""Raised when the client cannot reach the Lexicon Local API on construction."""
|
|
25
58
|
|
|
26
59
|
|
|
60
|
+
class LexiconAuthError(LexiconConnectionError):
|
|
61
|
+
"""Raised when Lexicon refuses the app: not approved, blocked, or a bad token.
|
|
62
|
+
|
|
63
|
+
Lexicon v1.12 and newer ask the user before an app can use the Local API.
|
|
64
|
+
``error_code`` holds Lexicon's code: ``6`` blocked, ``9`` bad or missing
|
|
65
|
+
token, ``10`` not approved yet, ``11`` already allowed but no token sent.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
message: str,
|
|
71
|
+
*,
|
|
72
|
+
error_code: Optional[int] = None,
|
|
73
|
+
status_code: Optional[int] = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
super().__init__(message)
|
|
76
|
+
self.error_code = error_code
|
|
77
|
+
self.status_code = status_code
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _error_body(response: Any) -> tuple[Optional[int], Optional[str]]:
|
|
81
|
+
"""Return the ``errorCode`` and ``message`` of a Lexicon error response."""
|
|
82
|
+
try:
|
|
83
|
+
body = response.json()
|
|
84
|
+
except (ValueError, AttributeError):
|
|
85
|
+
return None, None
|
|
86
|
+
if not isinstance(body, dict):
|
|
87
|
+
return None, None
|
|
88
|
+
code = body.get("errorCode")
|
|
89
|
+
message = body.get("message")
|
|
90
|
+
return (
|
|
91
|
+
code if isinstance(code, int) and not isinstance(code, bool) else None,
|
|
92
|
+
message if isinstance(message, str) else None,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
27
96
|
class Tools:
|
|
28
97
|
"""Namespace for helper tools."""
|
|
29
98
|
|
|
@@ -37,6 +106,7 @@ class Lexicon:
|
|
|
37
106
|
tracks: Tracks
|
|
38
107
|
playlists: Playlists
|
|
39
108
|
tags: Tags
|
|
109
|
+
player: Player
|
|
40
110
|
tools: Tools
|
|
41
111
|
|
|
42
112
|
def __init__(
|
|
@@ -49,6 +119,8 @@ class Lexicon:
|
|
|
49
119
|
raise_on_error: bool = False,
|
|
50
120
|
raw_enums: bool = True,
|
|
51
121
|
verify_connection: bool = True,
|
|
122
|
+
app_name: Optional[str] = None,
|
|
123
|
+
token: Optional[str] = None,
|
|
52
124
|
) -> None:
|
|
53
125
|
"""Create a Lexicon client bound to an API instance.
|
|
54
126
|
|
|
@@ -57,7 +129,8 @@ class Lexicon:
|
|
|
57
129
|
host
|
|
58
130
|
Hostname or IP for the Lexicon API.
|
|
59
131
|
port
|
|
60
|
-
API port number.
|
|
132
|
+
API port number. Starting in Lexicon v1.12 the user can change it in
|
|
133
|
+
the Local API settings.
|
|
61
134
|
default_timeout
|
|
62
135
|
Default request timeout in seconds.
|
|
63
136
|
session
|
|
@@ -71,12 +144,32 @@ class Lexicon:
|
|
|
71
144
|
If True (default), probe the Lexicon API on construction and raise
|
|
72
145
|
:class:`LexiconConnectionError` if it isn't reachable. Set to False
|
|
73
146
|
to construct without a running Lexicon (e.g. for tests).
|
|
147
|
+
|
|
148
|
+
Starting in Lexicon v1.12, the first request from an app Lexicon has
|
|
149
|
+
not seen brings up a popup asking the user to allow it. The probe
|
|
150
|
+
waits up to ``APPROVAL_TIMEOUT`` seconds for the answer and raises
|
|
151
|
+
:class:`LexiconAuthError` when the app is refused.
|
|
152
|
+
app_name
|
|
153
|
+
The name Lexicon shows the user in the approval popup and in its Local
|
|
154
|
+
API settings, sent as the ``X-Lexicon-App`` header. Defaults to the
|
|
155
|
+
``LEXICON_APP_NAME`` environment variable, then ``"lexicon-python"``.
|
|
156
|
+
Lexicon asks once per name, so give each tool its own.
|
|
157
|
+
token
|
|
158
|
+
The app's API token, sent as ``Authorization: Bearer <token>``.
|
|
159
|
+
Defaults to the ``LEXICON_TOKEN`` environment variable. Get one from
|
|
160
|
+
:meth:`pair`, from ``lex.token`` after the user allows the app, or
|
|
161
|
+
copy the API token from the Lexicon settings under Local API. On the
|
|
162
|
+
computer running Lexicon an allowed app is let in by name alone; from
|
|
163
|
+
another device the token is required. Lexicon v1.11 and older ignore
|
|
164
|
+
both headers.
|
|
74
165
|
"""
|
|
75
166
|
self.host = host or DEFAULT_HOST
|
|
76
167
|
self.port = int(port or LEXICON_PORT)
|
|
77
168
|
self.default_timeout = default_timeout
|
|
78
169
|
self.raise_on_error = raise_on_error
|
|
79
170
|
self.raw_enums = raw_enums
|
|
171
|
+
self.app_name = app_name or DEFAULT_APP_NAME
|
|
172
|
+
self.token: Optional[str] = token or os.environ.get("LEXICON_TOKEN") or None
|
|
80
173
|
self._logger = logging.getLogger(__name__)
|
|
81
174
|
self._session = session
|
|
82
175
|
|
|
@@ -87,35 +180,231 @@ class Lexicon:
|
|
|
87
180
|
)
|
|
88
181
|
self.tags: Tags = Tags(self)
|
|
89
182
|
self.tags.categories = TagCategories(self)
|
|
183
|
+
self.player: Player = Player(self, tracks=self.tracks)
|
|
90
184
|
self.tools = Tools()
|
|
91
185
|
|
|
92
186
|
if verify_connection:
|
|
93
187
|
self._verify_connection()
|
|
94
188
|
|
|
189
|
+
def _headers(self) -> dict[str, str]:
|
|
190
|
+
"""Return the headers that identify this app to Lexicon."""
|
|
191
|
+
headers = {"X-Lexicon-App": self.app_name}
|
|
192
|
+
if self.token:
|
|
193
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
194
|
+
return headers
|
|
195
|
+
|
|
196
|
+
def _remember_token(self, response: Any) -> None:
|
|
197
|
+
"""Keep the token Lexicon sends when the user allows this app.
|
|
198
|
+
|
|
199
|
+
Lexicon v1.12 returns it in the ``X-Lexicon-Token`` header of the request
|
|
200
|
+
that brought up the approval popup. It is never shown again, so the caller
|
|
201
|
+
should store ``lex.token``.
|
|
202
|
+
"""
|
|
203
|
+
headers = getattr(response, "headers", None)
|
|
204
|
+
if not headers:
|
|
205
|
+
return
|
|
206
|
+
new_token = headers.get("X-Lexicon-Token")
|
|
207
|
+
if isinstance(new_token, str) and new_token and new_token != self.token:
|
|
208
|
+
self.token = new_token
|
|
209
|
+
self._logger.info(
|
|
210
|
+
"Lexicon issued a token for app %r. Store lex.token and pass it as "
|
|
211
|
+
"token= (or LEXICON_TOKEN) next time; Lexicon cannot show it again.",
|
|
212
|
+
self.app_name,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def _auth_error(
|
|
216
|
+
self, response: Any, *, app_name: Optional[str] = None
|
|
217
|
+
) -> Optional[LexiconAuthError]:
|
|
218
|
+
"""Build a :class:`LexiconAuthError` when Lexicon refused the app."""
|
|
219
|
+
if response is None:
|
|
220
|
+
return None
|
|
221
|
+
code, message = _error_body(response)
|
|
222
|
+
if code not in _AUTH_ERRORS:
|
|
223
|
+
return None
|
|
224
|
+
hint = _AUTH_ERRORS[code].format(app=app_name or self.app_name)
|
|
225
|
+
detail = f" Lexicon said: {message}" if message else ""
|
|
226
|
+
return LexiconAuthError(
|
|
227
|
+
f"{hint}{detail}",
|
|
228
|
+
error_code=code,
|
|
229
|
+
status_code=getattr(response, "status_code", None),
|
|
230
|
+
)
|
|
231
|
+
|
|
95
232
|
def _verify_connection(self) -> None:
|
|
96
|
-
"""Probe the Lexicon API to confirm it's reachable.
|
|
233
|
+
"""Probe the Lexicon API to confirm it's reachable and lets this app in.
|
|
97
234
|
|
|
98
235
|
Sends a single ``GET /v1/playing`` request, bypassing the swallow-and-warn
|
|
99
236
|
behavior of :meth:`request`. Logs at INFO on success; raises
|
|
100
|
-
:class:`
|
|
237
|
+
:class:`LexiconAuthError` when Lexicon refuses the app and
|
|
238
|
+
:class:`LexiconConnectionError` on any other failure.
|
|
101
239
|
"""
|
|
102
240
|
url = f"http://{self.host}:{self.port}/v1/playing"
|
|
103
241
|
requester = self._session or requests
|
|
242
|
+
response = None
|
|
104
243
|
try:
|
|
105
|
-
response = requester.request(
|
|
244
|
+
response = requester.request(
|
|
245
|
+
"GET",
|
|
246
|
+
url,
|
|
247
|
+
headers=self._headers(),
|
|
248
|
+
timeout=max(self.default_timeout, APPROVAL_TIMEOUT),
|
|
249
|
+
)
|
|
106
250
|
response.raise_for_status()
|
|
107
|
-
except
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
251
|
+
except requests.HTTPError as exc:
|
|
252
|
+
# An error Response is falsy, so compare with None rather than using `or`.
|
|
253
|
+
auth_error = self._auth_error(
|
|
254
|
+
exc.response if exc.response is not None else response
|
|
255
|
+
)
|
|
256
|
+
if auth_error is not None:
|
|
257
|
+
raise auth_error from exc
|
|
258
|
+
raise LexiconConnectionError(
|
|
259
|
+
f"Lexicon at {self.host}:{self.port} answered with an error "
|
|
260
|
+
f"({exc.__class__.__name__}: {exc})."
|
|
261
|
+
) from exc
|
|
262
|
+
except (requests.ConnectionError, requests.Timeout) as exc:
|
|
112
263
|
raise LexiconConnectionError(
|
|
113
264
|
f"Could not reach Lexicon at {self.host}:{self.port} "
|
|
114
265
|
f"({exc.__class__.__name__}: {exc}). "
|
|
115
266
|
"Is Lexicon running with the Local API enabled?"
|
|
116
267
|
) from exc
|
|
268
|
+
self._remember_token(response)
|
|
117
269
|
self._logger.info("Connected to Lexicon at %s:%s", self.host, self.port)
|
|
118
270
|
|
|
271
|
+
def pair(self, name: Optional[str] = None, *, timeout: Optional[int] = None) -> str:
|
|
272
|
+
"""Ask the user to allow this app, and return its token.
|
|
273
|
+
|
|
274
|
+
Lexicon v1.12 and newer show the user a popup and this call waits for the
|
|
275
|
+
answer, up to two minutes. The token is also kept in ``lex.token``. Store
|
|
276
|
+
it: Lexicon only stores a hash, cannot show it again, and refuses a second
|
|
277
|
+
pairing for a name it already allowed.
|
|
278
|
+
|
|
279
|
+
Parameters
|
|
280
|
+
----------
|
|
281
|
+
name
|
|
282
|
+
The app name to pair. Defaults to ``app_name``, and becomes the new
|
|
283
|
+
``app_name`` so later requests match the token.
|
|
284
|
+
timeout
|
|
285
|
+
Timeout in seconds. Defaults to ``PAIR_TIMEOUT``.
|
|
286
|
+
|
|
287
|
+
Returns
|
|
288
|
+
-------
|
|
289
|
+
str
|
|
290
|
+
The token for this app.
|
|
291
|
+
|
|
292
|
+
Raises
|
|
293
|
+
------
|
|
294
|
+
LexiconAuthError
|
|
295
|
+
When the user blocks the app (code ``6``), does not answer or closes
|
|
296
|
+
the popup (``10``), or the name is already allowed (``11``; the user
|
|
297
|
+
has to remove the app in the Lexicon settings to pair again).
|
|
298
|
+
LexiconConnectionError
|
|
299
|
+
When Lexicon cannot be reached or answers without a token, for example
|
|
300
|
+
a Lexicon older than v1.12.
|
|
301
|
+
"""
|
|
302
|
+
app_name = name or self.app_name
|
|
303
|
+
url = f"http://{self.host}:{self.port}/v1/auth/pair"
|
|
304
|
+
requester = self._session or requests
|
|
305
|
+
headers = {"X-Lexicon-App": app_name}
|
|
306
|
+
response = None
|
|
307
|
+
try:
|
|
308
|
+
response = requester.request(
|
|
309
|
+
"POST",
|
|
310
|
+
url,
|
|
311
|
+
headers=headers,
|
|
312
|
+
json={"name": app_name},
|
|
313
|
+
timeout=timeout or PAIR_TIMEOUT,
|
|
314
|
+
)
|
|
315
|
+
response.raise_for_status()
|
|
316
|
+
except requests.HTTPError as exc:
|
|
317
|
+
auth_error = self._auth_error(
|
|
318
|
+
exc.response if exc.response is not None else response,
|
|
319
|
+
app_name=app_name,
|
|
320
|
+
)
|
|
321
|
+
if auth_error is not None:
|
|
322
|
+
raise auth_error from exc
|
|
323
|
+
raise LexiconConnectionError(
|
|
324
|
+
f"Pairing with Lexicon at {self.host}:{self.port} failed ({exc}). "
|
|
325
|
+
"Lexicon v1.11 and older have no pairing and need no token."
|
|
326
|
+
) from exc
|
|
327
|
+
except (requests.ConnectionError, requests.Timeout) as exc:
|
|
328
|
+
raise LexiconConnectionError(
|
|
329
|
+
f"Could not reach Lexicon at {self.host}:{self.port} "
|
|
330
|
+
f"({exc.__class__.__name__}: {exc})."
|
|
331
|
+
) from exc
|
|
332
|
+
|
|
333
|
+
token: Optional[str] = None
|
|
334
|
+
try:
|
|
335
|
+
body = response.json()
|
|
336
|
+
except ValueError:
|
|
337
|
+
body = None
|
|
338
|
+
data = body.get("data") if isinstance(body, dict) else None
|
|
339
|
+
if isinstance(data, dict) and isinstance(data.get("token"), str):
|
|
340
|
+
token = data["token"]
|
|
341
|
+
if not token:
|
|
342
|
+
header_token = response.headers.get("X-Lexicon-Token")
|
|
343
|
+
token = header_token if isinstance(header_token, str) else None
|
|
344
|
+
if not token:
|
|
345
|
+
raise LexiconConnectionError(
|
|
346
|
+
"Lexicon accepted the pairing request but sent no token."
|
|
347
|
+
)
|
|
348
|
+
self.app_name = app_name
|
|
349
|
+
self.token = token
|
|
350
|
+
return token
|
|
351
|
+
|
|
352
|
+
def control(
|
|
353
|
+
self,
|
|
354
|
+
action: ControlAction,
|
|
355
|
+
/,
|
|
356
|
+
*,
|
|
357
|
+
validation: ValidationMode = "warn",
|
|
358
|
+
timeout: Optional[int] = None,
|
|
359
|
+
**parameters: Any,
|
|
360
|
+
) -> bool:
|
|
361
|
+
"""Run a Lexicon command, the same ones that can be bound to a hotkey.
|
|
362
|
+
|
|
363
|
+
Parameters
|
|
364
|
+
----------
|
|
365
|
+
action
|
|
366
|
+
Action name, such as ``"MusicPlayer_Eject"``.
|
|
367
|
+
validation
|
|
368
|
+
Validation mode: ``"off"`` sends any action name, ``"warn"`` logs a
|
|
369
|
+
name missing from ``ControlAction`` and sends nothing, and ``"strict"``
|
|
370
|
+
raises on one. Use ``"off"`` for
|
|
371
|
+
``PluginRunAction_<pluginId>:<actionId>``, which is not in the list.
|
|
372
|
+
timeout
|
|
373
|
+
Timeout in seconds for this request.
|
|
374
|
+
**parameters
|
|
375
|
+
The action's parameters under their API names, such as
|
|
376
|
+
``lex.control("MusicPlayer_Load", id=123)``. See the Control Actions
|
|
377
|
+
schema in ``docs/lexicon-api-docs.yaml``.
|
|
378
|
+
|
|
379
|
+
Returns
|
|
380
|
+
-------
|
|
381
|
+
bool
|
|
382
|
+
True if Lexicon accepted the request, otherwise False.
|
|
383
|
+
|
|
384
|
+
Notes
|
|
385
|
+
-----
|
|
386
|
+
True does not mean the action did anything. Lexicon returns before the
|
|
387
|
+
action finishes, has no completion signal, and accepts an action whose
|
|
388
|
+
target is missing, such as a hotkey for the selected tracks with nothing
|
|
389
|
+
selected, or ``MusicPlayer_Load`` without ``id``.
|
|
390
|
+
|
|
391
|
+
``ControlAction`` follows the newest spec, so it can name actions that the
|
|
392
|
+
running Lexicon rejects. See ``docs/api-issues.md``.
|
|
393
|
+
"""
|
|
394
|
+
if validation != "off" and action not in CONTROL_ACTIONS:
|
|
395
|
+
if validation == "strict":
|
|
396
|
+
raise ValueError(f"Invalid action for control: {action}")
|
|
397
|
+
self._logger.warning("Invalid action for control: %s", action)
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
payload: dict[str, Any] = {"action": action}
|
|
401
|
+
if parameters:
|
|
402
|
+
payload["parameters"] = parameters
|
|
403
|
+
response = self.request("POST", "/control", json=payload, timeout=timeout)
|
|
404
|
+
# Lexicon 1.11.15 answers HTTP 200 with only a "message" when a handler throws
|
|
405
|
+
# an error that has no code. See docs/api-issues.md.
|
|
406
|
+
return isinstance(response, dict) and "message" not in response
|
|
407
|
+
|
|
119
408
|
def request(
|
|
120
409
|
self,
|
|
121
410
|
method: str,
|
|
@@ -158,6 +447,7 @@ class Lexicon:
|
|
|
158
447
|
url,
|
|
159
448
|
params=params,
|
|
160
449
|
json=json,
|
|
450
|
+
headers=self._headers(),
|
|
161
451
|
timeout=timeout or self.default_timeout,
|
|
162
452
|
)
|
|
163
453
|
response.raise_for_status()
|
|
@@ -178,6 +468,9 @@ class Lexicon:
|
|
|
178
468
|
error_msg = f"{exc}\nDetails: {error_body['detail']}"
|
|
179
469
|
except (ValueError, AttributeError, KeyError):
|
|
180
470
|
pass # Response wasn't JSON or didn't have expected fields
|
|
471
|
+
auth_error = self._auth_error(response)
|
|
472
|
+
if auth_error is not None:
|
|
473
|
+
error_msg = f"{error_msg}\n{auth_error}"
|
|
181
474
|
self._logger.warning("Request failed for %s %s: %s", method, url, error_msg)
|
|
182
475
|
return None
|
|
183
476
|
except Exception as exc: # noqa: BLE001 - surface request failures
|
|
@@ -186,6 +479,7 @@ class Lexicon:
|
|
|
186
479
|
self._logger.warning("Request failed for %s %s: %s", method, url, exc)
|
|
187
480
|
return None
|
|
188
481
|
|
|
482
|
+
self._remember_token(response)
|
|
189
483
|
if not response.content:
|
|
190
484
|
return None
|
|
191
485
|
try:
|
lexicon/resources/__init__.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Resource module exports."""
|
|
2
2
|
|
|
3
|
+
from .player import Player
|
|
3
4
|
from .playlist_tracks import PlaylistTracks
|
|
4
5
|
from .playlists import Playlists
|
|
5
6
|
from .tag_categories import TagCategories
|
|
@@ -7,6 +8,7 @@ from .tags import Tags
|
|
|
7
8
|
from .tracks import Tracks
|
|
8
9
|
|
|
9
10
|
__all__ = [
|
|
11
|
+
"Player",
|
|
10
12
|
"PlaylistTracks",
|
|
11
13
|
"Playlists",
|
|
12
14
|
"TagCategories",
|