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.
Files changed (51) hide show
  1. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/PKG-INFO +148 -12
  2. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/README.md +147 -11
  3. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/pyproject.toml +1 -1
  4. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/__init__.py +16 -1
  5. lexicon_python-0.5.0/src/lexicon/client.py +492 -0
  6. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/__init__.py +2 -0
  7. lexicon_python-0.5.0/src/lexicon/resources/control_types.py +450 -0
  8. lexicon_python-0.5.0/src/lexicon/resources/player.py +100 -0
  9. lexicon_python-0.5.0/src/lexicon/resources/player_types.py +49 -0
  10. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/playlists.py +35 -2
  11. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/playlists_types.py +2 -0
  12. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/tag_categories.py +41 -12
  13. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/tags_types.py +2 -0
  14. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/tracks.py +223 -35
  15. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/tracks_types.py +145 -38
  16. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon_python.egg-info/PKG-INFO +148 -12
  17. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon_python.egg-info/SOURCES.txt +4 -0
  18. lexicon_python-0.5.0/tests/test_client.py +459 -0
  19. lexicon_python-0.5.0/tests/test_player.py +174 -0
  20. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_playlists.py +23 -0
  21. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_tag_categories.py +45 -0
  22. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_tracks.py +471 -10
  23. lexicon_python-0.4.0/src/lexicon/client.py +0 -198
  24. lexicon_python-0.4.0/tests/test_client.py +0 -207
  25. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/LICENSE +0 -0
  26. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/setup.cfg +0 -0
  27. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/colors.py +0 -0
  28. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/_common_types.py +0 -0
  29. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/base.py +0 -0
  30. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/playlist_tracks.py +0 -0
  31. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/playlist_tracks_types.py +0 -0
  32. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/tag_categories_types.py +0 -0
  33. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/resources/tags.py +0 -0
  34. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/tools/__init__.py +0 -0
  35. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/tools/playlists.py +0 -0
  36. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/tools/tempo.py +0 -0
  37. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/tools/tracks.py +0 -0
  38. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon/utils.py +0 -0
  39. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon_python.egg-info/dependency_links.txt +0 -0
  40. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon_python.egg-info/requires.txt +0 -0
  41. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/src/lexicon_python.egg-info/top_level.txt +0 -0
  42. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_base.py +0 -0
  43. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_colors.py +0 -0
  44. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_common_types.py +0 -0
  45. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_integration_lifecycle.py +0 -0
  46. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_playlist_tracks.py +0 -0
  47. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_playlists_types.py +0 -0
  48. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_tags.py +0 -0
  49. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_tools_playlists.py +0 -0
  50. {lexicon_python-0.4.0 → lexicon_python-0.5.0}/tests/test_tools_tempo.py +0 -0
  51. {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.4.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
- - Pagination handled for `tracks.list()` (API returns 1000 per page)
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.9+
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
- lex = Lexicon() # verifies the API is reachable; raises LexiconConnectionError otherwise
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 Lexicon (e.g. for tests).
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()` results are capped at 1000 by the API.
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
- - There is no supported way to search for “no tags”; `NONE` is not accepted.
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, delete
342
- - `lex.playlists`: get, get_many, list (tree root), get_path, get_by_path, add, update, delete, choose
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
- - Pagination handled for `tracks.list()` (API returns 1000 per page)
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.9+
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
- lex = Lexicon() # verifies the API is reachable; raises LexiconConnectionError otherwise
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 Lexicon (e.g. for tests).
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()` results are capped at 1000 by the API.
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
- - There is no supported way to search for “no tags”; `NONE` is not accepted.
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, delete
327
- - `lex.playlists`: get, get_many, list (tree root), get_path, get_by_path, add, update, delete, choose
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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lexicon-python"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Python client for the Lexicon DJ API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,7 +1,16 @@
1
1
  """Public package surface for the lexicon Python client."""
2
2
 
3
- from .client import DEFAULT_HOST, LEXICON_PORT, Lexicon, LexiconConnectionError
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",