lexicon-python 0.4.0__tar.gz → 0.5.0__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.
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/PKG-INFO +148 -12
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/README.md +147 -11
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/pyproject.toml +1 -1
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/__init__.py +16 -1
- lexicon_python-0.5.0/src/lexicon/client.py +492 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/__init__.py +2 -0
- lexicon_python-0.5.0/src/lexicon/resources/control_types.py +450 -0
- lexicon_python-0.5.0/src/lexicon/resources/player.py +100 -0
- lexicon_python-0.5.0/src/lexicon/resources/player_types.py +49 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/playlists.py +35 -2
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/playlists_types.py +2 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/tag_categories.py +41 -12
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/tags_types.py +2 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/tracks.py +223 -35
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/tracks_types.py +145 -38
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon_python.egg-info/PKG-INFO +148 -12
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon_python.egg-info/SOURCES.txt +4 -0
- lexicon_python-0.5.0/tests/test_client.py +459 -0
- lexicon_python-0.5.0/tests/test_player.py +174 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_playlists.py +23 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_tag_categories.py +45 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_tracks.py +471 -10
- lexicon_python-0.4.0/src/lexicon/client.py +0 -198
- lexicon_python-0.4.0/tests/test_client.py +0 -207
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/LICENSE +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/setup.cfg +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/colors.py +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/_common_types.py +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/base.py +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/playlist_tracks.py +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/playlist_tracks_types.py +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/tag_categories_types.py +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/tags.py +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/tools/__init__.py +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/tools/playlists.py +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/tools/tempo.py +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/tools/tracks.py +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/utils.py +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon_python.egg-info/dependency_links.txt +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon_python.egg-info/requires.txt +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon_python.egg-info/top_level.txt +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_base.py +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_colors.py +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_common_types.py +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_integration_lifecycle.py +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_playlist_tracks.py +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_playlists_types.py +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_tags.py +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_tools_playlists.py +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_tools_tempo.py +0 -0
- {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_tools_tracks.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lexicon-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Python client for the Lexicon DJ API
|
|
5
5
|
Author-email: Garrison Burger <burgerga123@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -23,8 +23,10 @@ API.
|
|
|
23
23
|
|
|
24
24
|
## Features
|
|
25
25
|
|
|
26
|
-
- Resource grouped client (`lex.tracks`, `lex.playlists`, `lex.tags`)
|
|
27
|
-
-
|
|
26
|
+
- Resource grouped client (`lex.tracks`, `lex.playlists`, `lex.tags`, `lex.player`)
|
|
27
|
+
- Player and hotkey commands via `lex.player` and `lex.control()`
|
|
28
|
+
- Pagination handled for `tracks.list()` and `tracks.search()` (API returns 1000 per page)
|
|
29
|
+
- Lexicon v1.12 app approval and tokens, with v1.11 still supported
|
|
28
30
|
- Validation modes for inputs: warn (default), strict, or off
|
|
29
31
|
- Typed responses and payload hints (TypedDicts and Literals)
|
|
30
32
|
- Optional interactive playlist chooser via `InquirerPy`
|
|
@@ -32,8 +34,9 @@ API.
|
|
|
32
34
|
|
|
33
35
|
## Requirements
|
|
34
36
|
|
|
35
|
-
- Python 3.
|
|
36
|
-
- Lexicon DJ running with Local API enabled
|
|
37
|
+
- Python 3.10+
|
|
38
|
+
- Lexicon DJ running with the Local API enabled (Lexicon settings, Local API). Lexicon v1.12 is the target; v1.11 works
|
|
39
|
+
for everything that existed in it (see [Lexicon versions](#lexicon-versions)).
|
|
37
40
|
|
|
38
41
|
## Install
|
|
39
42
|
|
|
@@ -46,7 +49,9 @@ pip install lexicon-python
|
|
|
46
49
|
```python
|
|
47
50
|
from lexicon import Lexicon
|
|
48
51
|
|
|
49
|
-
|
|
52
|
+
# Verifies the API is reachable; raises LexiconConnectionError otherwise. On Lexicon v1.12
|
|
53
|
+
# the first run shows the user a popup asking to allow the app; see Authentication.
|
|
54
|
+
lex = Lexicon(app_name="My Lexicon Tool")
|
|
50
55
|
playlist = lex.playlists.choose()
|
|
51
56
|
|
|
52
57
|
# list tracks (default fields: id, artist, title, bpm, key, ...)
|
|
@@ -86,15 +91,56 @@ Other options:
|
|
|
86
91
|
- `session`: optional `requests.Session`
|
|
87
92
|
- `raise_on_error`: raise HTTP errors instead of returning None
|
|
88
93
|
- `verify_connection` (default `True`): probe the API on construction and raise `LexiconConnectionError` if Lexicon
|
|
89
|
-
isn't reachable. Set to `False` to build a client without a running
|
|
94
|
+
isn't reachable, or `LexiconAuthError` if it refuses the app. Set to `False` to build a client without a running
|
|
95
|
+
Lexicon (e.g. for tests).
|
|
96
|
+
- `app_name` (default `"lexicon-python"`): the name the user sees in Lexicon's approval popup and settings.
|
|
97
|
+
- `token`: the app's API token for Lexicon v1.12 (see below).
|
|
90
98
|
|
|
91
99
|
Environment variables:
|
|
92
100
|
|
|
93
101
|
```bash
|
|
94
102
|
export LEXICON_HOST=localhost
|
|
95
|
-
export LEXICON_PORT=48624
|
|
103
|
+
export LEXICON_PORT=48624 # Lexicon v1.12 lets the user change it in the Local API settings
|
|
104
|
+
export LEXICON_APP_NAME="My Lexicon Tool"
|
|
105
|
+
export LEXICON_TOKEN=... # optional, see Authentication
|
|
96
106
|
```
|
|
97
107
|
|
|
108
|
+
## Authentication
|
|
109
|
+
|
|
110
|
+
Starting in Lexicon v1.12, the user has to allow each app before it can use the API. Every request names the app in the
|
|
111
|
+
`X-Lexicon-App` header (`app_name`, default `"lexicon-python"`). Lexicon asks once per name, so give each tool its own.
|
|
112
|
+
|
|
113
|
+
- **First run:** the first request from a new app name shows the user a popup, and Lexicon holds the request until they
|
|
114
|
+
answer. `Lexicon()` waits up to 70 seconds for that. When the user allows the app, Lexicon sends a token, which the
|
|
115
|
+
client keeps in `lex.token`.
|
|
116
|
+
- **On the computer running Lexicon:** an allowed app is let in by name alone, so a local script needs no token.
|
|
117
|
+
- **From another device:** every request needs the token. Store `lex.token` and pass it back as `token=` or
|
|
118
|
+
`LEXICON_TOKEN`. Lexicon only keeps a hash, so it cannot show the token again.
|
|
119
|
+
- **Pairing explicitly:** `lex.pair()` asks the user right away (waiting up to two minutes) and returns the token.
|
|
120
|
+
- **Personal scripts:** the user can instead copy the API token from the Lexicon settings under Local API. Lexicon
|
|
121
|
+
always lets that token in.
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from lexicon import Lexicon, LexiconAuthError
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
lex = Lexicon(app_name="My Lexicon Tool", token=load_saved_token())
|
|
128
|
+
except LexiconAuthError as exc:
|
|
129
|
+
print(exc.error_code, exc) # 10: not answered yet, 6: blocked, 11: allowed but token missing
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`LexiconAuthError` (a `LexiconConnectionError`) carries Lexicon's error code:
|
|
133
|
+
|
|
134
|
+
| Code | Meaning |
|
|
135
|
+
| ---- | ---------------------------------------------------------------------------------------------- |
|
|
136
|
+
| `10` | The popup was not answered in time, or was closed. Lexicon waits a minute before asking again. |
|
|
137
|
+
| `6` | The user blocked the app. Unblock it in the Lexicon settings. |
|
|
138
|
+
| `11` | The name is already allowed, but the request did not send its token. |
|
|
139
|
+
| `9` | The token is wrong, or the request looked like it came from a web page. |
|
|
140
|
+
|
|
141
|
+
The user can block or remove an app at any time in the Lexicon settings, which stops its token from working. Lexicon
|
|
142
|
+
v1.11 and older have no authentication and ignore these headers.
|
|
143
|
+
|
|
98
144
|
## Validation Modes
|
|
99
145
|
|
|
100
146
|
Many methods accept a `validation` parameter with three modes:
|
|
@@ -163,6 +209,12 @@ added = lex.tracks.add(["/path/to/file1.mp3", "/path/to/file2.mp3"])
|
|
|
163
209
|
updated = lex.tracks.update(123, {"title": "New Title"})
|
|
164
210
|
lex.tracks.delete([123, 456])
|
|
165
211
|
|
|
212
|
+
# Lexicon v1.12: the selection in Lexicon, and relocating a track to another file
|
|
213
|
+
ids = lex.tracks.selected() # [12, 7, 33]
|
|
214
|
+
track = lex.tracks.get("selected") # the topmost selected track
|
|
215
|
+
lex.tracks.update("selected", {"rating": 5}) # every selected track
|
|
216
|
+
moved = lex.tracks.update_location(123, "/Music/new/file.mp3")
|
|
217
|
+
|
|
166
218
|
# Replace tempomarkers while keeping cuepoints anchored to the same beats
|
|
167
219
|
lex.tracks.update_tempogrid(123, [{"startTime": 0.0, "bpm": 124.0}])
|
|
168
220
|
|
|
@@ -172,7 +224,15 @@ lex.tracks.analyze([123, 456], analyze_beatgrid=True, do_not_overwrite=False)
|
|
|
172
224
|
|
|
173
225
|
Notes:
|
|
174
226
|
|
|
175
|
-
- `tracks.search()`
|
|
227
|
+
- `tracks.search()` returns every match on Lexicon v1.12, which pages results 1000 at a time; pass `limit=` to cap it.
|
|
228
|
+
Lexicon v1.11 returns at most 1000 matches and logs a warning when there are more.
|
|
229
|
+
- `tracks.update()` returns the track as Lexicon stored it. On v1.12 that comes from the PATCH response; on older
|
|
230
|
+
versions the client fetches it again.
|
|
231
|
+
- A `cuepoints` or `tempomarkers` edit replaces the whole list, and Lexicon clears the `data` of any cue or marker sent
|
|
232
|
+
without it. That `data` holds what a DJ app import stored (Traktor, Rekordbox, ...), so to keep it, edit the list from
|
|
233
|
+
`tracks.get()` and send it back. `update_tempogrid()` does this for cuepoints.
|
|
234
|
+
- `tracks.update_location()` only changes the library; the file is never moved. Lexicon normalizes the path, so read
|
|
235
|
+
`location` back from the returned track.
|
|
176
236
|
- `tracks.get_many()` preserves input order and returns `None` for missing IDs.
|
|
177
237
|
- `tracks.get()` always returns the full record (no `fields` parameter).
|
|
178
238
|
- For `tracks.list()` / `tracks.search()`:
|
|
@@ -227,9 +287,12 @@ lex.tracks.search({"artist": "Daft Punk"})
|
|
|
227
287
|
lex.tracks.search({"bpm": "120"})
|
|
228
288
|
lex.tracks.search({"bpm": "120-128"})
|
|
229
289
|
lex.tracks.search({"bpm": ">=120"})
|
|
290
|
+
lex.tracks.search({"bpm": "120~128"}) # stops before 128
|
|
230
291
|
|
|
231
292
|
# date filters (YYYY-MM-DD)
|
|
293
|
+
# - ">" is that day and later, "<" is before that day
|
|
232
294
|
lex.tracks.search({"dateAdded": "2024-01-01"})
|
|
295
|
+
lex.tracks.search({"dateAdded": ">2024-01-01"})
|
|
233
296
|
|
|
234
297
|
# tag filters (comma-separated names)
|
|
235
298
|
# - default: OR across tags
|
|
@@ -238,6 +301,11 @@ lex.tracks.search({"dateAdded": "2024-01-01"})
|
|
|
238
301
|
lex.tracks.search({"tags": "Rock, Chill"}) # Rock OR Chill
|
|
239
302
|
lex.tracks.search({"tags": "~Rock, Chill"}) # Rock AND Chill
|
|
240
303
|
lex.tracks.search({"tags": "~Rock, !Chill"}) # Rock AND NOT Chill
|
|
304
|
+
lex.tracks.search({"tags": None}) # no tags at all (v1.12)
|
|
305
|
+
|
|
306
|
+
# Lexicon v1.12: look tracks up by id, or by file extension
|
|
307
|
+
lex.tracks.search({"id": [12, 48, 153]}, fields=["id", "title"])
|
|
308
|
+
lex.tracks.search({"fileType": "flac"})
|
|
241
309
|
```
|
|
242
310
|
|
|
243
311
|
Tag filter details:
|
|
@@ -246,7 +314,11 @@ Tag filter details:
|
|
|
246
314
|
- Whitespace is ignored around commas.
|
|
247
315
|
- `~` at the start switches from OR to AND for the list.
|
|
248
316
|
- `!` before a tag name negates that tag.
|
|
249
|
-
-
|
|
317
|
+
- `None` finds tracks without any tag on Lexicon v1.12 (unless a tag is labeled "None").
|
|
318
|
+
|
|
319
|
+
Lexicon v1.12 rejects a filter on a field it cannot filter with an error, where v1.11 silently ignored it. The `id`
|
|
320
|
+
filter is the dangerous case on v1.11, which ignores it and matches the whole library, so `search()` returns `None` when
|
|
321
|
+
more tracks come back than ids were asked for.
|
|
250
322
|
|
|
251
323
|
Sort can be expressed in two shapes:
|
|
252
324
|
|
|
@@ -264,7 +336,10 @@ tree = lex.playlists.list()
|
|
|
264
336
|
|
|
265
337
|
new_id = lex.playlists.add("Demo Playlist", playlist_type="playlist", parent_id=1)
|
|
266
338
|
lex.playlists.update(new_id, name="Renamed Playlist")
|
|
339
|
+
lex.playlists.update(new_id, position=0) # v1.12: the others shift down, like dragging
|
|
267
340
|
lex.playlists.delete([new_id])
|
|
341
|
+
|
|
342
|
+
selected = lex.playlists.selected() # v1.12: IDs selected in the playlist tree
|
|
268
343
|
```
|
|
269
344
|
|
|
270
345
|
Playlist type accepts:
|
|
@@ -296,8 +371,43 @@ categories = lex.tags.categories.list()
|
|
|
296
371
|
new_cat = lex.tags.categories.add(label="Demo Category", color="red")
|
|
297
372
|
lex.tags.categories.update(new_cat["id"], label="Renamed Category")
|
|
298
373
|
lex.tags.categories.delete(new_cat["id"])
|
|
374
|
+
|
|
375
|
+
# Lexicon v1.12: reorder a category's tags (every tag it has, in the new order; tags from
|
|
376
|
+
# other categories move in), and move the category itself
|
|
377
|
+
lex.tags.categories.update(new_cat["id"], tags=[12, 10, 11])
|
|
378
|
+
lex.tags.categories.update(new_cat["id"], position=0)
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
## Player
|
|
382
|
+
|
|
383
|
+
```python
|
|
384
|
+
playing = lex.player.playing() # None when no track is loaded
|
|
385
|
+
if playing:
|
|
386
|
+
print(playing["track"]["title"], playing["currentTime"], playing["progress"])
|
|
387
|
+
|
|
388
|
+
queue = lex.player.queue() # the tracks after the loaded one
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
`playing()` returns the loaded track whether it is playing or paused. For Beatport, Apple Music, and Spotify tracks,
|
|
392
|
+
`track` is not a library row: `id` is the service's own track ID, and `duration` is the length of the preview clip.
|
|
393
|
+
|
|
394
|
+
## Control
|
|
395
|
+
|
|
396
|
+
`lex.control()` runs the commands that can be bound to a hotkey. Parameters use their API names.
|
|
397
|
+
|
|
398
|
+
```python
|
|
399
|
+
lex.control("MusicPlayer_TogglePlay")
|
|
400
|
+
lex.control("MusicPlayer_Load", id=123)
|
|
401
|
+
lex.control("MusicPlayer_Seek", seconds=30)
|
|
299
402
|
```
|
|
300
403
|
|
|
404
|
+
It returns `True` when Lexicon accepts the request. Lexicon does not wait for the action to finish and has no completion
|
|
405
|
+
signal, and it accepts an action whose target is missing, such as one for the selected tracks with nothing selected.
|
|
406
|
+
Names are checked against the `ControlAction` list; pass `validation="off"` for one that is not listed. The list covers
|
|
407
|
+
Lexicon v1.11 and v1.12, so it holds both names of the actions v1.12 renamed: v1.12 calls the `Direct2Cdj_*` player
|
|
408
|
+
actions `DeckConnect_*` (`DeckConnect_LoadDeck`, `DeckConnect_LoadDeck1` to `8`, `DeckConnect_LoadEmptyDeck`) and
|
|
409
|
+
rejects the old names.
|
|
410
|
+
|
|
301
411
|
## Tools
|
|
302
412
|
|
|
303
413
|
Interactive playlist chooser (requires InquirerPy):
|
|
@@ -338,11 +448,32 @@ payload = lex.request("GET", "/tracks", params={"fields": "all"})
|
|
|
338
448
|
|
|
339
449
|
High level namespaces (full mapping in `docs/resource-map.md`):
|
|
340
450
|
|
|
341
|
-
- `lex.tracks`: get, get_many, list, search, add, update,
|
|
342
|
-
|
|
451
|
+
- `lex.tracks`: get, get_many, list, search, selected, add, update, update_location, update_tempogrid, add_tags,
|
|
452
|
+
remove_tags, delete, analyze
|
|
453
|
+
- `lex.playlists`: get, get_many, list (tree root), selected, get_path, get_by_path, add, update, delete, choose
|
|
343
454
|
- `lex.playlists.tracks`: list (IDs), get (track dicts), add, remove, update
|
|
344
455
|
- `lex.tags`: list, add, update, delete
|
|
345
456
|
- `lex.tags.categories`: list, add, update, delete
|
|
457
|
+
- `lex.player`: playing, queue
|
|
458
|
+
- `lex.control()`: run a control action
|
|
459
|
+
- `lex.pair()`: ask the user to allow the app and get its token (v1.12)
|
|
460
|
+
|
|
461
|
+
## Lexicon versions
|
|
462
|
+
|
|
463
|
+
The client targets Lexicon v1.12 and keeps working with v1.11. Requests are the same, and the client falls back where
|
|
464
|
+
v1.11 answers differently:
|
|
465
|
+
|
|
466
|
+
| Feature | v1.12 | v1.11 |
|
|
467
|
+
| ---------------------------------------------------- | ----------------------- | -------------------------------------------------------- |
|
|
468
|
+
| App approval, `token`, `pair()` | required | no authentication; headers ignored |
|
|
469
|
+
| `selected()`, `get("selected")`, `update_location()` | yes | `None` (endpoint does not exist) |
|
|
470
|
+
| `search()` past 1000 matches | pages through all | first 1000, with a warning |
|
|
471
|
+
| `search()` filters `id`, `fileType`, `tags=None` | yes | `id`: `None`; `fileType`: error; `tags=None`: no matches |
|
|
472
|
+
| `update()` result | from the PATCH response | fetched again |
|
|
473
|
+
| `playlists.update(position=)` | inserts, others shift | stores the number, positions can overlap |
|
|
474
|
+
| `tags.categories.update(tags=, position=)` | yes | accepted but ignored |
|
|
475
|
+
| Cue `activeLoop` | sent as `true`/`false` | not tested with booleans |
|
|
476
|
+
| DeckConnect actions | `DeckConnect_*` | `Direct2Cdj_*` |
|
|
346
477
|
|
|
347
478
|
## Type Hints
|
|
348
479
|
|
|
@@ -392,6 +523,11 @@ make run-tests
|
|
|
392
523
|
make run-integration-tests
|
|
393
524
|
```
|
|
394
525
|
|
|
526
|
+
The integration fixture quits Lexicon and renames `main.db` while it runs. If the Lexicon data folder is synced to
|
|
527
|
+
another machine (Syncthing, Dropbox, iCloud), pause the sync first. On Lexicon v1.12 the first run shows an approval
|
|
528
|
+
popup for "lexicon-python integration tests"; Lexicon remembers the answer outside the library, so later runs do not ask
|
|
529
|
+
again.
|
|
530
|
+
|
|
395
531
|
### Linting and Formatting
|
|
396
532
|
|
|
397
533
|
The project uses [ruff](https://docs.astral.sh/ruff/) for both linting and formatting.
|
|
@@ -8,8 +8,10 @@ API.
|
|
|
8
8
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
|
-
- Resource grouped client (`lex.tracks`, `lex.playlists`, `lex.tags`)
|
|
12
|
-
-
|
|
11
|
+
- Resource grouped client (`lex.tracks`, `lex.playlists`, `lex.tags`, `lex.player`)
|
|
12
|
+
- Player and hotkey commands via `lex.player` and `lex.control()`
|
|
13
|
+
- Pagination handled for `tracks.list()` and `tracks.search()` (API returns 1000 per page)
|
|
14
|
+
- Lexicon v1.12 app approval and tokens, with v1.11 still supported
|
|
13
15
|
- Validation modes for inputs: warn (default), strict, or off
|
|
14
16
|
- Typed responses and payload hints (TypedDicts and Literals)
|
|
15
17
|
- Optional interactive playlist chooser via `InquirerPy`
|
|
@@ -17,8 +19,9 @@ API.
|
|
|
17
19
|
|
|
18
20
|
## Requirements
|
|
19
21
|
|
|
20
|
-
- Python 3.
|
|
21
|
-
- Lexicon DJ running with Local API enabled
|
|
22
|
+
- Python 3.10+
|
|
23
|
+
- Lexicon DJ running with the Local API enabled (Lexicon settings, Local API). Lexicon v1.12 is the target; v1.11 works
|
|
24
|
+
for everything that existed in it (see [Lexicon versions](#lexicon-versions)).
|
|
22
25
|
|
|
23
26
|
## Install
|
|
24
27
|
|
|
@@ -31,7 +34,9 @@ pip install lexicon-python
|
|
|
31
34
|
```python
|
|
32
35
|
from lexicon import Lexicon
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
# Verifies the API is reachable; raises LexiconConnectionError otherwise. On Lexicon v1.12
|
|
38
|
+
# the first run shows the user a popup asking to allow the app; see Authentication.
|
|
39
|
+
lex = Lexicon(app_name="My Lexicon Tool")
|
|
35
40
|
playlist = lex.playlists.choose()
|
|
36
41
|
|
|
37
42
|
# list tracks (default fields: id, artist, title, bpm, key, ...)
|
|
@@ -71,15 +76,56 @@ Other options:
|
|
|
71
76
|
- `session`: optional `requests.Session`
|
|
72
77
|
- `raise_on_error`: raise HTTP errors instead of returning None
|
|
73
78
|
- `verify_connection` (default `True`): probe the API on construction and raise `LexiconConnectionError` if Lexicon
|
|
74
|
-
isn't reachable. Set to `False` to build a client without a running
|
|
79
|
+
isn't reachable, or `LexiconAuthError` if it refuses the app. Set to `False` to build a client without a running
|
|
80
|
+
Lexicon (e.g. for tests).
|
|
81
|
+
- `app_name` (default `"lexicon-python"`): the name the user sees in Lexicon's approval popup and settings.
|
|
82
|
+
- `token`: the app's API token for Lexicon v1.12 (see below).
|
|
75
83
|
|
|
76
84
|
Environment variables:
|
|
77
85
|
|
|
78
86
|
```bash
|
|
79
87
|
export LEXICON_HOST=localhost
|
|
80
|
-
export LEXICON_PORT=48624
|
|
88
|
+
export LEXICON_PORT=48624 # Lexicon v1.12 lets the user change it in the Local API settings
|
|
89
|
+
export LEXICON_APP_NAME="My Lexicon Tool"
|
|
90
|
+
export LEXICON_TOKEN=... # optional, see Authentication
|
|
81
91
|
```
|
|
82
92
|
|
|
93
|
+
## Authentication
|
|
94
|
+
|
|
95
|
+
Starting in Lexicon v1.12, the user has to allow each app before it can use the API. Every request names the app in the
|
|
96
|
+
`X-Lexicon-App` header (`app_name`, default `"lexicon-python"`). Lexicon asks once per name, so give each tool its own.
|
|
97
|
+
|
|
98
|
+
- **First run:** the first request from a new app name shows the user a popup, and Lexicon holds the request until they
|
|
99
|
+
answer. `Lexicon()` waits up to 70 seconds for that. When the user allows the app, Lexicon sends a token, which the
|
|
100
|
+
client keeps in `lex.token`.
|
|
101
|
+
- **On the computer running Lexicon:** an allowed app is let in by name alone, so a local script needs no token.
|
|
102
|
+
- **From another device:** every request needs the token. Store `lex.token` and pass it back as `token=` or
|
|
103
|
+
`LEXICON_TOKEN`. Lexicon only keeps a hash, so it cannot show the token again.
|
|
104
|
+
- **Pairing explicitly:** `lex.pair()` asks the user right away (waiting up to two minutes) and returns the token.
|
|
105
|
+
- **Personal scripts:** the user can instead copy the API token from the Lexicon settings under Local API. Lexicon
|
|
106
|
+
always lets that token in.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from lexicon import Lexicon, LexiconAuthError
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
lex = Lexicon(app_name="My Lexicon Tool", token=load_saved_token())
|
|
113
|
+
except LexiconAuthError as exc:
|
|
114
|
+
print(exc.error_code, exc) # 10: not answered yet, 6: blocked, 11: allowed but token missing
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`LexiconAuthError` (a `LexiconConnectionError`) carries Lexicon's error code:
|
|
118
|
+
|
|
119
|
+
| Code | Meaning |
|
|
120
|
+
| ---- | ---------------------------------------------------------------------------------------------- |
|
|
121
|
+
| `10` | The popup was not answered in time, or was closed. Lexicon waits a minute before asking again. |
|
|
122
|
+
| `6` | The user blocked the app. Unblock it in the Lexicon settings. |
|
|
123
|
+
| `11` | The name is already allowed, but the request did not send its token. |
|
|
124
|
+
| `9` | The token is wrong, or the request looked like it came from a web page. |
|
|
125
|
+
|
|
126
|
+
The user can block or remove an app at any time in the Lexicon settings, which stops its token from working. Lexicon
|
|
127
|
+
v1.11 and older have no authentication and ignore these headers.
|
|
128
|
+
|
|
83
129
|
## Validation Modes
|
|
84
130
|
|
|
85
131
|
Many methods accept a `validation` parameter with three modes:
|
|
@@ -148,6 +194,12 @@ added = lex.tracks.add(["/path/to/file1.mp3", "/path/to/file2.mp3"])
|
|
|
148
194
|
updated = lex.tracks.update(123, {"title": "New Title"})
|
|
149
195
|
lex.tracks.delete([123, 456])
|
|
150
196
|
|
|
197
|
+
# Lexicon v1.12: the selection in Lexicon, and relocating a track to another file
|
|
198
|
+
ids = lex.tracks.selected() # [12, 7, 33]
|
|
199
|
+
track = lex.tracks.get("selected") # the topmost selected track
|
|
200
|
+
lex.tracks.update("selected", {"rating": 5}) # every selected track
|
|
201
|
+
moved = lex.tracks.update_location(123, "/Music/new/file.mp3")
|
|
202
|
+
|
|
151
203
|
# Replace tempomarkers while keeping cuepoints anchored to the same beats
|
|
152
204
|
lex.tracks.update_tempogrid(123, [{"startTime": 0.0, "bpm": 124.0}])
|
|
153
205
|
|
|
@@ -157,7 +209,15 @@ lex.tracks.analyze([123, 456], analyze_beatgrid=True, do_not_overwrite=False)
|
|
|
157
209
|
|
|
158
210
|
Notes:
|
|
159
211
|
|
|
160
|
-
- `tracks.search()`
|
|
212
|
+
- `tracks.search()` returns every match on Lexicon v1.12, which pages results 1000 at a time; pass `limit=` to cap it.
|
|
213
|
+
Lexicon v1.11 returns at most 1000 matches and logs a warning when there are more.
|
|
214
|
+
- `tracks.update()` returns the track as Lexicon stored it. On v1.12 that comes from the PATCH response; on older
|
|
215
|
+
versions the client fetches it again.
|
|
216
|
+
- A `cuepoints` or `tempomarkers` edit replaces the whole list, and Lexicon clears the `data` of any cue or marker sent
|
|
217
|
+
without it. That `data` holds what a DJ app import stored (Traktor, Rekordbox, ...), so to keep it, edit the list from
|
|
218
|
+
`tracks.get()` and send it back. `update_tempogrid()` does this for cuepoints.
|
|
219
|
+
- `tracks.update_location()` only changes the library; the file is never moved. Lexicon normalizes the path, so read
|
|
220
|
+
`location` back from the returned track.
|
|
161
221
|
- `tracks.get_many()` preserves input order and returns `None` for missing IDs.
|
|
162
222
|
- `tracks.get()` always returns the full record (no `fields` parameter).
|
|
163
223
|
- For `tracks.list()` / `tracks.search()`:
|
|
@@ -212,9 +272,12 @@ lex.tracks.search({"artist": "Daft Punk"})
|
|
|
212
272
|
lex.tracks.search({"bpm": "120"})
|
|
213
273
|
lex.tracks.search({"bpm": "120-128"})
|
|
214
274
|
lex.tracks.search({"bpm": ">=120"})
|
|
275
|
+
lex.tracks.search({"bpm": "120~128"}) # stops before 128
|
|
215
276
|
|
|
216
277
|
# date filters (YYYY-MM-DD)
|
|
278
|
+
# - ">" is that day and later, "<" is before that day
|
|
217
279
|
lex.tracks.search({"dateAdded": "2024-01-01"})
|
|
280
|
+
lex.tracks.search({"dateAdded": ">2024-01-01"})
|
|
218
281
|
|
|
219
282
|
# tag filters (comma-separated names)
|
|
220
283
|
# - default: OR across tags
|
|
@@ -223,6 +286,11 @@ lex.tracks.search({"dateAdded": "2024-01-01"})
|
|
|
223
286
|
lex.tracks.search({"tags": "Rock, Chill"}) # Rock OR Chill
|
|
224
287
|
lex.tracks.search({"tags": "~Rock, Chill"}) # Rock AND Chill
|
|
225
288
|
lex.tracks.search({"tags": "~Rock, !Chill"}) # Rock AND NOT Chill
|
|
289
|
+
lex.tracks.search({"tags": None}) # no tags at all (v1.12)
|
|
290
|
+
|
|
291
|
+
# Lexicon v1.12: look tracks up by id, or by file extension
|
|
292
|
+
lex.tracks.search({"id": [12, 48, 153]}, fields=["id", "title"])
|
|
293
|
+
lex.tracks.search({"fileType": "flac"})
|
|
226
294
|
```
|
|
227
295
|
|
|
228
296
|
Tag filter details:
|
|
@@ -231,7 +299,11 @@ Tag filter details:
|
|
|
231
299
|
- Whitespace is ignored around commas.
|
|
232
300
|
- `~` at the start switches from OR to AND for the list.
|
|
233
301
|
- `!` before a tag name negates that tag.
|
|
234
|
-
-
|
|
302
|
+
- `None` finds tracks without any tag on Lexicon v1.12 (unless a tag is labeled "None").
|
|
303
|
+
|
|
304
|
+
Lexicon v1.12 rejects a filter on a field it cannot filter with an error, where v1.11 silently ignored it. The `id`
|
|
305
|
+
filter is the dangerous case on v1.11, which ignores it and matches the whole library, so `search()` returns `None` when
|
|
306
|
+
more tracks come back than ids were asked for.
|
|
235
307
|
|
|
236
308
|
Sort can be expressed in two shapes:
|
|
237
309
|
|
|
@@ -249,7 +321,10 @@ tree = lex.playlists.list()
|
|
|
249
321
|
|
|
250
322
|
new_id = lex.playlists.add("Demo Playlist", playlist_type="playlist", parent_id=1)
|
|
251
323
|
lex.playlists.update(new_id, name="Renamed Playlist")
|
|
324
|
+
lex.playlists.update(new_id, position=0) # v1.12: the others shift down, like dragging
|
|
252
325
|
lex.playlists.delete([new_id])
|
|
326
|
+
|
|
327
|
+
selected = lex.playlists.selected() # v1.12: IDs selected in the playlist tree
|
|
253
328
|
```
|
|
254
329
|
|
|
255
330
|
Playlist type accepts:
|
|
@@ -281,8 +356,43 @@ categories = lex.tags.categories.list()
|
|
|
281
356
|
new_cat = lex.tags.categories.add(label="Demo Category", color="red")
|
|
282
357
|
lex.tags.categories.update(new_cat["id"], label="Renamed Category")
|
|
283
358
|
lex.tags.categories.delete(new_cat["id"])
|
|
359
|
+
|
|
360
|
+
# Lexicon v1.12: reorder a category's tags (every tag it has, in the new order; tags from
|
|
361
|
+
# other categories move in), and move the category itself
|
|
362
|
+
lex.tags.categories.update(new_cat["id"], tags=[12, 10, 11])
|
|
363
|
+
lex.tags.categories.update(new_cat["id"], position=0)
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
## Player
|
|
367
|
+
|
|
368
|
+
```python
|
|
369
|
+
playing = lex.player.playing() # None when no track is loaded
|
|
370
|
+
if playing:
|
|
371
|
+
print(playing["track"]["title"], playing["currentTime"], playing["progress"])
|
|
372
|
+
|
|
373
|
+
queue = lex.player.queue() # the tracks after the loaded one
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
`playing()` returns the loaded track whether it is playing or paused. For Beatport, Apple Music, and Spotify tracks,
|
|
377
|
+
`track` is not a library row: `id` is the service's own track ID, and `duration` is the length of the preview clip.
|
|
378
|
+
|
|
379
|
+
## Control
|
|
380
|
+
|
|
381
|
+
`lex.control()` runs the commands that can be bound to a hotkey. Parameters use their API names.
|
|
382
|
+
|
|
383
|
+
```python
|
|
384
|
+
lex.control("MusicPlayer_TogglePlay")
|
|
385
|
+
lex.control("MusicPlayer_Load", id=123)
|
|
386
|
+
lex.control("MusicPlayer_Seek", seconds=30)
|
|
284
387
|
```
|
|
285
388
|
|
|
389
|
+
It returns `True` when Lexicon accepts the request. Lexicon does not wait for the action to finish and has no completion
|
|
390
|
+
signal, and it accepts an action whose target is missing, such as one for the selected tracks with nothing selected.
|
|
391
|
+
Names are checked against the `ControlAction` list; pass `validation="off"` for one that is not listed. The list covers
|
|
392
|
+
Lexicon v1.11 and v1.12, so it holds both names of the actions v1.12 renamed: v1.12 calls the `Direct2Cdj_*` player
|
|
393
|
+
actions `DeckConnect_*` (`DeckConnect_LoadDeck`, `DeckConnect_LoadDeck1` to `8`, `DeckConnect_LoadEmptyDeck`) and
|
|
394
|
+
rejects the old names.
|
|
395
|
+
|
|
286
396
|
## Tools
|
|
287
397
|
|
|
288
398
|
Interactive playlist chooser (requires InquirerPy):
|
|
@@ -323,11 +433,32 @@ payload = lex.request("GET", "/tracks", params={"fields": "all"})
|
|
|
323
433
|
|
|
324
434
|
High level namespaces (full mapping in `docs/resource-map.md`):
|
|
325
435
|
|
|
326
|
-
- `lex.tracks`: get, get_many, list, search, add, update,
|
|
327
|
-
|
|
436
|
+
- `lex.tracks`: get, get_many, list, search, selected, add, update, update_location, update_tempogrid, add_tags,
|
|
437
|
+
remove_tags, delete, analyze
|
|
438
|
+
- `lex.playlists`: get, get_many, list (tree root), selected, get_path, get_by_path, add, update, delete, choose
|
|
328
439
|
- `lex.playlists.tracks`: list (IDs), get (track dicts), add, remove, update
|
|
329
440
|
- `lex.tags`: list, add, update, delete
|
|
330
441
|
- `lex.tags.categories`: list, add, update, delete
|
|
442
|
+
- `lex.player`: playing, queue
|
|
443
|
+
- `lex.control()`: run a control action
|
|
444
|
+
- `lex.pair()`: ask the user to allow the app and get its token (v1.12)
|
|
445
|
+
|
|
446
|
+
## Lexicon versions
|
|
447
|
+
|
|
448
|
+
The client targets Lexicon v1.12 and keeps working with v1.11. Requests are the same, and the client falls back where
|
|
449
|
+
v1.11 answers differently:
|
|
450
|
+
|
|
451
|
+
| Feature | v1.12 | v1.11 |
|
|
452
|
+
| ---------------------------------------------------- | ----------------------- | -------------------------------------------------------- |
|
|
453
|
+
| App approval, `token`, `pair()` | required | no authentication; headers ignored |
|
|
454
|
+
| `selected()`, `get("selected")`, `update_location()` | yes | `None` (endpoint does not exist) |
|
|
455
|
+
| `search()` past 1000 matches | pages through all | first 1000, with a warning |
|
|
456
|
+
| `search()` filters `id`, `fileType`, `tags=None` | yes | `id`: `None`; `fileType`: error; `tags=None`: no matches |
|
|
457
|
+
| `update()` result | from the PATCH response | fetched again |
|
|
458
|
+
| `playlists.update(position=)` | inserts, others shift | stores the number, positions can overlap |
|
|
459
|
+
| `tags.categories.update(tags=, position=)` | yes | accepted but ignored |
|
|
460
|
+
| Cue `activeLoop` | sent as `true`/`false` | not tested with booleans |
|
|
461
|
+
| DeckConnect actions | `DeckConnect_*` | `Direct2Cdj_*` |
|
|
331
462
|
|
|
332
463
|
## Type Hints
|
|
333
464
|
|
|
@@ -377,6 +508,11 @@ make run-tests
|
|
|
377
508
|
make run-integration-tests
|
|
378
509
|
```
|
|
379
510
|
|
|
511
|
+
The integration fixture quits Lexicon and renames `main.db` while it runs. If the Lexicon data folder is synced to
|
|
512
|
+
another machine (Syncthing, Dropbox, iCloud), pause the sync first. On Lexicon v1.12 the first run shows an approval
|
|
513
|
+
popup for "lexicon-python integration tests"; Lexicon remembers the answer outside the library, so later runs do not ask
|
|
514
|
+
again.
|
|
515
|
+
|
|
380
516
|
### Linting and Formatting
|
|
381
517
|
|
|
382
518
|
The project uses [ruff](https://docs.astral.sh/ruff/) for both linting and formatting.
|
|
@@ -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",
|