lexicon-python 0.3.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 (55) hide show
  1. lexicon_python-0.5.0/PKG-INFO +588 -0
  2. lexicon_python-0.5.0/README.md +573 -0
  3. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/pyproject.toml +1 -1
  4. lexicon_python-0.5.0/src/lexicon/__init__.py +50 -0
  5. lexicon_python-0.5.0/src/lexicon/client.py +492 -0
  6. lexicon_python-0.5.0/src/lexicon/colors.py +29 -0
  7. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/src/lexicon/resources/__init__.py +2 -0
  8. lexicon_python-0.5.0/src/lexicon/resources/control_types.py +450 -0
  9. lexicon_python-0.5.0/src/lexicon/resources/player.py +100 -0
  10. lexicon_python-0.5.0/src/lexicon/resources/player_types.py +49 -0
  11. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/src/lexicon/resources/playlists.py +35 -2
  12. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/src/lexicon/resources/playlists_types.py +2 -0
  13. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/src/lexicon/resources/tag_categories.py +41 -12
  14. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/src/lexicon/resources/tags_types.py +2 -0
  15. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/src/lexicon/resources/tracks.py +443 -38
  16. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/src/lexicon/resources/tracks_types.py +205 -39
  17. lexicon_python-0.5.0/src/lexicon/tools/tempo.py +74 -0
  18. lexicon_python-0.5.0/src/lexicon_python.egg-info/PKG-INFO +588 -0
  19. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/src/lexicon_python.egg-info/SOURCES.txt +9 -0
  20. lexicon_python-0.5.0/tests/test_client.py +459 -0
  21. lexicon_python-0.5.0/tests/test_colors.py +34 -0
  22. lexicon_python-0.5.0/tests/test_integration_lifecycle.py +366 -0
  23. lexicon_python-0.5.0/tests/test_player.py +174 -0
  24. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/tests/test_playlists.py +23 -0
  25. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/tests/test_tag_categories.py +45 -0
  26. lexicon_python-0.5.0/tests/test_tools_tempo.py +79 -0
  27. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/tests/test_tracks.py +734 -11
  28. lexicon_python-0.3.0/PKG-INFO +0 -393
  29. lexicon_python-0.3.0/README.md +0 -378
  30. lexicon_python-0.3.0/src/lexicon/__init__.py +0 -23
  31. lexicon_python-0.3.0/src/lexicon/client.py +0 -162
  32. lexicon_python-0.3.0/src/lexicon_python.egg-info/PKG-INFO +0 -393
  33. lexicon_python-0.3.0/tests/test_client.py +0 -164
  34. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/LICENSE +0 -0
  35. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/setup.cfg +0 -0
  36. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/src/lexicon/resources/_common_types.py +0 -0
  37. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/src/lexicon/resources/base.py +0 -0
  38. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/src/lexicon/resources/playlist_tracks.py +0 -0
  39. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/src/lexicon/resources/playlist_tracks_types.py +0 -0
  40. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/src/lexicon/resources/tag_categories_types.py +0 -0
  41. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/src/lexicon/resources/tags.py +0 -0
  42. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/src/lexicon/tools/__init__.py +0 -0
  43. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/src/lexicon/tools/playlists.py +0 -0
  44. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/src/lexicon/tools/tracks.py +0 -0
  45. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/src/lexicon/utils.py +0 -0
  46. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/src/lexicon_python.egg-info/dependency_links.txt +0 -0
  47. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/src/lexicon_python.egg-info/requires.txt +0 -0
  48. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/src/lexicon_python.egg-info/top_level.txt +0 -0
  49. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/tests/test_base.py +0 -0
  50. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/tests/test_common_types.py +0 -0
  51. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/tests/test_playlist_tracks.py +0 -0
  52. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/tests/test_playlists_types.py +0 -0
  53. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/tests/test_tags.py +0 -0
  54. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/tests/test_tools_playlists.py +0 -0
  55. {lexicon_python-0.3.0 → lexicon_python-0.5.0}/tests/test_tools_tracks.py +0 -0
@@ -0,0 +1,588 @@
1
+ Metadata-Version: 2.4
2
+ Name: lexicon-python
3
+ Version: 0.5.0
4
+ Summary: Python client for the Lexicon DJ API
5
+ Author-email: Garrison Burger <burgerga123@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/photonicvelocity/lexicon-python
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: InquirerPy>=0.3
12
+ Requires-Dist: requests>=2.31
13
+ Requires-Dist: typing_extensions>=4.0
14
+ Dynamic: license-file
15
+
16
+ # Lexicon Python
17
+
18
+ Python client for the Lexicon DJ Local API.
19
+
20
+ This SDK wraps the Lexicon Local API with resource groups, sensible defaults, and optional validation. It is designed
21
+ for scripting library automation, playlist management, and metadata edits while keeping a clean escape hatch to the raw
22
+ API.
23
+
24
+ ## Features
25
+
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
30
+ - Validation modes for inputs: warn (default), strict, or off
31
+ - Typed responses and payload hints (TypedDicts and Literals)
32
+ - Optional interactive playlist chooser via `InquirerPy`
33
+ - Raw request escape hatch via `lex.request(...)`
34
+
35
+ ## Requirements
36
+
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)).
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install lexicon-python
45
+ ```
46
+
47
+ ## Quickstart
48
+
49
+ ```python
50
+ from lexicon import Lexicon
51
+
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")
55
+ playlist = lex.playlists.choose()
56
+
57
+ # list tracks (default fields: id, artist, title, bpm, key, ...)
58
+ tracks = lex.tracks.list(limit=10) or []
59
+ for t in tracks:
60
+ print(t.get("artist"), "-", t.get("title"))
61
+
62
+ # search — pass fields=[...] for a narrow projection, or fields="all" for the
63
+ # full payload (tempomarkers, cuepoints, tags, etc.)
64
+ results = lex.tracks.search({"artist": "Daft Punk"}) or []
65
+ print("matches:", len(results))
66
+
67
+ # get a single track by id — always returns the full record
68
+ full = lex.tracks.get(123)
69
+
70
+ # get a playlist by path
71
+ playlist = lex.playlists.get_by_path(["Genres", "Drum & Bass"], playlist_type="folder")
72
+ print(playlist)
73
+ ```
74
+
75
+ ## Configuration
76
+
77
+ By default the client targets:
78
+
79
+ - Host: `localhost`
80
+ - Port: `48624`
81
+
82
+ You can override these via constructor args or environment variables.
83
+
84
+ ```python
85
+ lex = Lexicon(host="127.0.0.1", port=48624, raise_on_error=False)
86
+ ```
87
+
88
+ Other options:
89
+
90
+ - `default_timeout`: request timeout in seconds
91
+ - `session`: optional `requests.Session`
92
+ - `raise_on_error`: raise HTTP errors instead of returning None
93
+ - `verify_connection` (default `True`): probe the API on construction and raise `LexiconConnectionError` if Lexicon
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).
98
+
99
+ Environment variables:
100
+
101
+ ```bash
102
+ export LEXICON_HOST=localhost
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
106
+ ```
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
+
144
+ ## Validation Modes
145
+
146
+ Many methods accept a `validation` parameter with three modes:
147
+
148
+ - `"warn"` (default): invalid inputs are skipped with a warning. This avoids API failures, but intended changes may be
149
+ ignored.
150
+ - `"strict"`: invalid inputs raise `ValueError`.
151
+ - `"off"`: skips normalization and sends inputs as-is (inputs must match API-native shapes)
152
+
153
+ Example:
154
+
155
+ ```python
156
+ lex.tracks.search({"rating": "bad"}, validation="warn") # logs warning
157
+ lex.tracks.search({"rating": "bad"}, validation="strict") # raises
158
+ lex.tracks.search({"rating": "bad"}, validation="off") # sends as-is
159
+ ```
160
+
161
+ ## Convenience vs Raw API
162
+
163
+ This SDK adds several quality-of-life behaviors on top of the raw Lexicon API. In general, response shapes are unwrapped
164
+ (e.g., `"data": {...}` is removed and single-item lists are collapsed to a single dict).
165
+
166
+ ### Additional Methods (Not in the Raw API)
167
+
168
+ - `tracks.get_many()` repeats `get()` and preserves input order.
169
+ - `playlists.get_many()` repeats `get()` and preserves input order.
170
+ - `playlists.tracks.get()` fetches track dicts in playlist order.
171
+ - `playlists.tracks.update()` replaces the full track list (remove + add).
172
+ - `playlists.choose()` provides an interactive chooser (wraps list + get).
173
+ - `playlists.get_path()` resolves a path from a playlist tree.
174
+
175
+ ### Input Normalization (Broader Accepted Inputs)
176
+
177
+ - **ID normalization**: Many methods accept lists of IDs instead of a single ID for easier batch operations.
178
+ - **Field selection**: By default, `tracks.list()` and `tracks.search()` return a minimal set of fields rather than the
179
+ full payload:
180
+ - `id`, `artist`, `title`, `albumTitle`, `bpm`, `key`, `duration`, `year`
181
+ - Fields used as a search filter or sort item are also returned.
182
+ - **Search filter normalization**:
183
+ - Text fields accept `None` (becomes `"NONE"` in filter context).
184
+ - Numeric filters accept `None` (becomes `"0"`).
185
+ - Date filters accept `YYYY-MM-DD`, full datetime strings, or `datetime.date` / `datetime.datetime` inputs (time is
186
+ stripped).
187
+ - Comparisons (`>YYYY-MM-DD`) are warned/blocked because the API currently ignores them.
188
+ - **Sort normalization**:
189
+ - Accepts tuple shorthand: `[("title", "asc")]`.
190
+ - **Track update helpers**:
191
+ - Cuepoint and tempomarker entries are normalized (e.g., cuepoint type accepts name/number variants).
192
+ - Invalid entries can be skipped in `"warn"` mode without failing the update.
193
+
194
+ If no SDK normalization is desired, use `validation="off"` and pass API-native payloads. API-native shapes are also
195
+ accepted in `"warn"`/`"strict"`; those modes simply add normalization/validation on top. For fully raw access,
196
+ `lex.request(...)` can always be called directly.
197
+
198
+ ## Tracks
199
+
200
+ Common operations:
201
+
202
+ ```python
203
+ track = lex.tracks.get(123)
204
+ tracks = lex.tracks.get_many([1, 2, 3])
205
+ tracks = lex.tracks.list(limit=100)
206
+ tracks = lex.tracks.search({"artist": "Daft Punk"})
207
+
208
+ added = lex.tracks.add(["/path/to/file1.mp3", "/path/to/file2.mp3"])
209
+ updated = lex.tracks.update(123, {"title": "New Title"})
210
+ lex.tracks.delete([123, 456])
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
+
218
+ # Replace tempomarkers while keeping cuepoints anchored to the same beats
219
+ lex.tracks.update_tempogrid(123, [{"startTime": 0.0, "bpm": 124.0}])
220
+
221
+ # Start Lexicon's analyzer on specific tracks
222
+ lex.tracks.analyze([123, 456], analyze_beatgrid=True, do_not_overwrite=False)
223
+ ```
224
+
225
+ Notes:
226
+
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.
236
+ - `tracks.get_many()` preserves input order and returns `None` for missing IDs.
237
+ - `tracks.get()` always returns the full record (no `fields` parameter).
238
+ - For `tracks.list()` / `tracks.search()`:
239
+ - `fields=None` returns a minimal default set.
240
+ - In `validation="off"` mode, `fields=None` returns the full API payload.
241
+ - Pass a list of field names for a narrow projection (`fields=["id", "location"]`).
242
+ - `fields="all"` or `fields="*"` requests the full payload.
243
+ - Comma-separated strings (e.g. `"id,location"`) are _not_ accepted; pass a list. With `validation="warn"` the call
244
+ falls back to the default field set and logs.
245
+ - `tracks.add()` returns track dicts, but analysis fields (tempo markers, key, etc.) may be populated later by Lexicon.
246
+ - `tracks.update_tempogrid()` replaces tempomarkers while preserving each cuepoint's beat position by rewriting
247
+ `startTime`/`endTime` against the new grid. Use `tracks.update(track_id, {"tempomarkers": [...]})` directly if you
248
+ want the raw field replacement (cuepoints' seconds stay put, beats shift).
249
+ - `tracks.analyze()` returns as soon as Lexicon accepts the request. Analysis runs in the background and the API has no
250
+ completion signal, so poll `tracks.get()` and watch `tempomarkers`, `bpm`, `key`, or `dateModified`.
251
+ - `tracks.analyze(tempo_range=...)` takes one of the eight ids Lexicon's analyze dropdown stores: `"0_300"` (Any),
252
+ `"58_115"`, `"68_135"`, `"78_155"`, `"88_175"`, `"98_195"`, `"128_255"`, `"180_300"` (180+). An equivalent
253
+ `(min, max)` tuple works too. The value is written to Lexicon's stored analysis setting and persists after the call;
254
+ the boolean options apply to that run only.
255
+
256
+ ### Track Payload Shapes
257
+
258
+ Rich-payload fields like `cuepoints` and `tempomarkers` aren't in the default field set; request them explicitly
259
+ (`fields=["cuepoints", "tempomarkers"]`) or ask for everything (`fields="all"`). Their shapes are typed via
260
+ `CuePointResponse` and `TempoMarkerResponse`, both re-exported from `lexicon`:
261
+
262
+ ```python
263
+ from lexicon import TrackResponse, TempoMarkerResponse, CuePointResponse
264
+ ```
265
+
266
+ Color values (cuepoint/tag/etc. `color` fields) are Lexicon-specific name strings declared by the `Color` literal type.
267
+ To convert a name to RGB:
268
+
269
+ ```python
270
+ from lexicon import color_rgb
271
+
272
+ color_rgb("red_dark") # → (158, 15, 7)
273
+ ```
274
+
275
+ ### Track Search, Filters, and Sort
276
+
277
+ `tracks.search(filter=...)` accepts a dict of field names and values. The SDK validates fields/values in
278
+ `"warn"`/`"strict"` modes and can send API-native values in `"off"` mode.
279
+
280
+ Examples:
281
+
282
+ ```python
283
+ # text filters
284
+ lex.tracks.search({"artist": "Daft Punk"})
285
+
286
+ # numeric filters (strings)
287
+ lex.tracks.search({"bpm": "120"})
288
+ lex.tracks.search({"bpm": "120-128"})
289
+ lex.tracks.search({"bpm": ">=120"})
290
+ lex.tracks.search({"bpm": "120~128"}) # stops before 128
291
+
292
+ # date filters (YYYY-MM-DD)
293
+ # - ">" is that day and later, "<" is before that day
294
+ lex.tracks.search({"dateAdded": "2024-01-01"})
295
+ lex.tracks.search({"dateAdded": ">2024-01-01"})
296
+
297
+ # tag filters (comma-separated names)
298
+ # - default: OR across tags
299
+ # - prefix with "~" to require ALL tags (AND)
300
+ # - prefix with "!" to exclude a tag
301
+ lex.tracks.search({"tags": "Rock, Chill"}) # Rock OR Chill
302
+ lex.tracks.search({"tags": "~Rock, Chill"}) # Rock AND Chill
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"})
309
+ ```
310
+
311
+ Tag filter details:
312
+
313
+ - Input is a single string with comma-separated tag names.
314
+ - Whitespace is ignored around commas.
315
+ - `~` at the start switches from OR to AND for the list.
316
+ - `!` before a tag name negates that tag.
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.
322
+
323
+ Sort can be expressed in two shapes:
324
+
325
+ - API-native: list of dicts: `[{"field": "title", "dir": "asc"}]`
326
+ - Alternative: list of tuples: `[("title", "asc")]`
327
+
328
+ API-native dicts work in all modes; `validation="off"` requires the dict shape.
329
+
330
+ ## Playlists
331
+
332
+ ```python
333
+ playlist = lex.playlists.get(42)
334
+ playlist = lex.playlists.get_by_path(["Genres", "Drum & Bass"], playlist_type="playlist")
335
+ tree = lex.playlists.list()
336
+
337
+ new_id = lex.playlists.add("Demo Playlist", playlist_type="playlist", parent_id=1)
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
340
+ lex.playlists.delete([new_id])
341
+
342
+ selected = lex.playlists.selected() # v1.12: IDs selected in the playlist tree
343
+ ```
344
+
345
+ Playlist type accepts:
346
+
347
+ - `"folder"`, `"playlist"`, `"smartlist"`
348
+ - `1`, `2`, `3` (or string numerals `"1"`, `"2"`, `"3"`)
349
+
350
+ ### Playlist tracks helpers
351
+
352
+ For getting the tracks of a playlist or editing the tracklist.
353
+
354
+ ```python
355
+ track_ids = lex.playlists.tracks.list(42)
356
+ tracks = lex.playlists.tracks.get(42)
357
+ lex.playlists.tracks.add(42, [1, 2, 3])
358
+ lex.playlists.tracks.remove(42, [1, 2])
359
+ lex.playlists.tracks.update(42, [3, 2, 1])
360
+ ```
361
+
362
+ ## Tags
363
+
364
+ ```python
365
+ tags = lex.tags.list()
366
+ new_tag = lex.tags.add(category_id=1, label="Demo Tag")
367
+ lex.tags.update(new_tag["id"], label="Renamed Tag")
368
+ lex.tags.delete(new_tag["id"])
369
+
370
+ categories = lex.tags.categories.list()
371
+ new_cat = lex.tags.categories.add(label="Demo Category", color="red")
372
+ lex.tags.categories.update(new_cat["id"], label="Renamed Category")
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)
402
+ ```
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
+
411
+ ## Tools
412
+
413
+ Interactive playlist chooser (requires InquirerPy):
414
+
415
+ ```python
416
+ choice = lex.playlists.choose()
417
+ print(choice)
418
+
419
+ # Or avoid an API call if you already have the tree
420
+ playlist_tree = lex.playlists.list()
421
+ choice = lex.tools.playlists.choose_playlist(playlist_tree) if playlist_tree else None
422
+ print(choice)
423
+ ```
424
+
425
+ Helper to resolve a playlist path from a tree:
426
+
427
+ ```python
428
+ path = lex.playlists.get_path(42)
429
+ print(path)
430
+
431
+ # Or avoid an API call if you already have the tree
432
+ playlist_tree = lex.playlists.list()
433
+ path = lex.tools.playlists.get_path_from_tree(playlist_tree, playlist_id=42)
434
+ print(path)
435
+
436
+ # -> ["Genres", "Drum & Bass"]
437
+ ```
438
+
439
+ ## Raw Requests (Escape Hatch)
440
+
441
+ The API can always be accessed directly with `lex.request`:
442
+
443
+ ```python
444
+ payload = lex.request("GET", "/tracks", params={"fields": "all"})
445
+ ```
446
+
447
+ ## Resource Overview
448
+
449
+ High level namespaces (full mapping in `docs/resource-map.md`):
450
+
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
454
+ - `lex.playlists.tracks`: list (IDs), get (track dicts), add, remove, update
455
+ - `lex.tags`: list, add, update, delete
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_*` |
477
+
478
+ ## Type Hints
479
+
480
+ The SDK includes TypedDict and Literal types for payloads and enums. These are intended to improve editor autocomplete
481
+ and static checks.
482
+
483
+ For full payload schemas and endpoint details, refer to the Lexicon API docs:
484
+
485
+ - https://www.lexicondj.com/docs/developers/api
486
+ - https://www.lexicondj.com/developer/api-docs.yaml
487
+
488
+ ## Development
489
+
490
+ ### Setup
491
+
492
+ #### Pip
493
+
494
+ Ensure that you have Python 3.9+ installed locally. To install all runtime and dev dependencies into a local virtual
495
+ environment using pip:
496
+
497
+ ```bash
498
+ python -m venv .venv
499
+ source .venv/bin/activate
500
+ pip install -e ".[dev]"
501
+ ```
502
+
503
+ #### uv
504
+
505
+ To use [uv](https://docs.astral.sh/uv/) to install all runtime and dev dependencies into a local virtual environment,
506
+ simply install `uv` and run:
507
+
508
+ ```bash
509
+ uv sync --dev
510
+ ```
511
+
512
+ The lockfile (`uv.lock`) is checked in to ensure reproducible installs.
513
+
514
+ ### Running Tests
515
+
516
+ ```bash
517
+ # Unit tests
518
+ make run-tests
519
+
520
+ # Integration tests (requires Lexicon running)
521
+ # Note: Integration tests enforce an empty library state to avoid destructive edits on existing libraries.
522
+ # The fixture setup will back up the existing library, clear it for testing, and restore it afterward.
523
+ make run-integration-tests
524
+ ```
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
+
531
+ ### Linting and Formatting
532
+
533
+ The project uses [ruff](https://docs.astral.sh/ruff/) for both linting and formatting.
534
+
535
+ `make test` runs the full suite: format, lint (with auto-fix), then tests. `make fix` runs all auto-fixers (lint +
536
+ format) without running tests.
537
+
538
+ ```bash
539
+ make test # format-fix → lint-fix → format-check → lint-check → tests
540
+ make fix # lint-fix → format-fix
541
+ make clean # remove __pycache__, .pytest_cache, .ruff_cache, etc.
542
+ ```
543
+
544
+ If you want to run the linters or formatters manually, you can use the following commands:
545
+
546
+ ```bash
547
+ make lint-check # check for lint issues
548
+ make lint-fix # auto-fix lint issues
549
+ make format-check # check formatting
550
+ make format-fix # auto-fix formatting
551
+ ```
552
+
553
+ ### Pre-commit hook (recommended)
554
+
555
+ The repo ships a [pre-commit](https://pre-commit.com/) config (`.pre-commit-config.yaml`) that mirrors the CI lint job.
556
+ After cloning, install the hook once:
557
+
558
+ ```bash
559
+ uv tool install pre-commit # or: pipx install pre-commit
560
+ pre-commit install
561
+ ```
562
+
563
+ `git commit` now runs `ruff format` + `ruff check --fix` on staged files and blocks the commit on any failure. Run
564
+ against the whole repo any time with:
565
+
566
+ ```bash
567
+ pre-commit run --all-files
568
+ ```
569
+
570
+ The ruff version is pinned in `.pre-commit-config.yaml`; bump it when CI bumps to keep them aligned.
571
+
572
+ ### CI
573
+
574
+ GitHub Actions runs on every push to `main` and on pull requests targeting those branches. The pipeline includes:
575
+
576
+ - **Tests** across Python 3.9, 3.10, 3.11, and 3.12
577
+ - **Lint and format checks** via ruff on Python 3.12
578
+
579
+ Before opening a PR, make sure `make test` passes locally.
580
+
581
+ ### Pull Requests
582
+
583
+ A PR template is provided at `.github/pull_request_template.md`. When opening a PR, fill in the description, check the
584
+ relevant change-type boxes, and confirm testing/checklist items.
585
+
586
+ ## License
587
+
588
+ MIT (see `LICENSE`).