lexicon-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lexicon/__init__.py
ADDED
lexicon/lexicon.py
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
"""Utility helpers and client for interacting with the Lexicon API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
8
|
+
from typing import Callable, Iterable, Optional, Sequence
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
try: # Choose notebook vs. console automatically when available
|
|
13
|
+
from tqdm.auto import tqdm # type: ignore
|
|
14
|
+
except Exception as exc: # pragma: no cover - surface missing dependency clearly
|
|
15
|
+
raise ImportError("tqdm is required for progress reporting") from exc
|
|
16
|
+
|
|
17
|
+
LEXICON_PORT = int(os.environ.get("LEXICON_PORT", "48624"))
|
|
18
|
+
DEFAULT_HOST = os.environ.get("LEXICON_HOST", "localhost")
|
|
19
|
+
|
|
20
|
+
TRACK_SOURCES: tuple[str, ...] = ("non-archived", "all", "archived", "incoming")
|
|
21
|
+
|
|
22
|
+
TRACK_FIELDS: tuple[str, ...] = (
|
|
23
|
+
"id", "type", "title", "artist", "albumTitle", "label", "remixer", "mix", "composer", "producer", "grouping",
|
|
24
|
+
"lyricist", "comment", "key", "genre", "bpm", "rating", "color", "year", "duration", "bitrate", "playCount",
|
|
25
|
+
"location", "lastPlayed", "dateAdded", "dateModified", "sizeBytes", "sampleRate", "fileType", "trackNumber",
|
|
26
|
+
"energy", "danceability", "popularity", "happiness", "extra1", "extra2", "tags", "importSource", "locationUnique",
|
|
27
|
+
"tempomarkers", "cuepoints", "incoming", "archived", "archivedSince", "beatshiftCase", "fingerprint",
|
|
28
|
+
"streamingService", "streamingId",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
class LexiconClient:
|
|
32
|
+
"""Thin client for the Lexicon REST API."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
host: Optional[str] = None,
|
|
38
|
+
port: Optional[int | str] = None,
|
|
39
|
+
default_timeout: int = 20,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.host = host or DEFAULT_HOST
|
|
42
|
+
self.port = int(port or LEXICON_PORT)
|
|
43
|
+
self.default_timeout = default_timeout
|
|
44
|
+
self._logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
# Metadata helpers
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
def available_track_sources(self) -> tuple[str, ...]:
|
|
50
|
+
"""Return the valid ``source`` selector values for endpoints."""
|
|
51
|
+
|
|
52
|
+
return TRACK_SOURCES
|
|
53
|
+
|
|
54
|
+
def available_track_fields(self) -> tuple[str, ...]:
|
|
55
|
+
"""Return the full list of track fields."""
|
|
56
|
+
|
|
57
|
+
return TRACK_FIELDS
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
# Low-level helpers
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
def _build_url(self, path: str) -> str:
|
|
63
|
+
if not path.startswith("/"):
|
|
64
|
+
path = "/" + path
|
|
65
|
+
return f"http://{self.host}:{self.port}{path}"
|
|
66
|
+
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
# Playlist API Wrappers
|
|
69
|
+
# - See https://www.lexicondj.com/docs/developers/api
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
#region
|
|
72
|
+
|
|
73
|
+
# GET /v1/playlists
|
|
74
|
+
def get_playlists(self, *, timeout: Optional[int] = None) -> Optional[dict]:
|
|
75
|
+
"""
|
|
76
|
+
Return the root folder dictionary via the ``/v1/playlists`` endpoint.
|
|
77
|
+
"""
|
|
78
|
+
endpoint = self._build_url("/v1/playlists")
|
|
79
|
+
try:
|
|
80
|
+
response = requests.get(
|
|
81
|
+
endpoint,
|
|
82
|
+
timeout=timeout or self.default_timeout
|
|
83
|
+
)
|
|
84
|
+
response.raise_for_status()
|
|
85
|
+
payload = response.json() or {}
|
|
86
|
+
except Exception as exc: # noqa: BLE001 - expose networking failures to caller
|
|
87
|
+
self._logger.warning("Could not reach %s: %s", endpoint, exc)
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
data = payload.get("data") if isinstance(payload, dict) else None
|
|
91
|
+
playlists_root = data.get("playlists") if isinstance(data, dict) else None
|
|
92
|
+
root_entry = playlists_root[0] if isinstance(playlists_root, list) and playlists_root else None
|
|
93
|
+
|
|
94
|
+
if (isinstance(root_entry, dict)
|
|
95
|
+
and root_entry.get("type") == "1"
|
|
96
|
+
and root_entry.get("name") == "ROOT"
|
|
97
|
+
and isinstance(root_entry.get("playlists"), list)
|
|
98
|
+
):
|
|
99
|
+
return root_entry
|
|
100
|
+
|
|
101
|
+
self._logger.warning("Response did not contain expected root playlists structure")
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
# GET /v1/playlist
|
|
105
|
+
def get_playlist(self, playlist_id: int, *, timeout: Optional[int] = None) -> dict | None:
|
|
106
|
+
"""
|
|
107
|
+
Get a single playlist from the Lexicon library by ID. Via ``/v1/playlist`` endpoint.
|
|
108
|
+
|
|
109
|
+
** Current known API issue: Retrieving the "ROOT" folder (ID 1) returns a non de-duplicated
|
|
110
|
+
trackIds list. Use set(trackIds) to get unique IDs.
|
|
111
|
+
"""
|
|
112
|
+
endpoint = self._build_url("/v1/playlist")
|
|
113
|
+
try:
|
|
114
|
+
response = requests.get(
|
|
115
|
+
endpoint,
|
|
116
|
+
params={"id": playlist_id},
|
|
117
|
+
timeout=timeout or self.default_timeout,
|
|
118
|
+
)
|
|
119
|
+
response.raise_for_status()
|
|
120
|
+
payload = response.json() or {}
|
|
121
|
+
except Exception as exc: # noqa: BLE001 - expose networking failures to caller
|
|
122
|
+
self._logger.warning("Could not reach %s: %s", endpoint, exc)
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
data = payload.get("data") if isinstance(payload, dict) else None
|
|
126
|
+
playlist = data.get("playlist") if isinstance(data, dict) else None
|
|
127
|
+
|
|
128
|
+
if isinstance(playlist, dict):
|
|
129
|
+
return playlist
|
|
130
|
+
self._logger.warning("Playlist %s not found in response", playlist_id)
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
# GET /v1/playlist-by-path
|
|
134
|
+
def get_playlist_by_path(
|
|
135
|
+
self,
|
|
136
|
+
playlist_path: Sequence[str],
|
|
137
|
+
playlist_type: Optional[int] = None,
|
|
138
|
+
*,
|
|
139
|
+
timeout: Optional[int] = None,
|
|
140
|
+
) -> dict | None:
|
|
141
|
+
"""
|
|
142
|
+
Get a playlist from the Lexicon library by its folder path.
|
|
143
|
+
Via ``/v1/playlist-by-path`` endpoint.
|
|
144
|
+
"""
|
|
145
|
+
endpoint = self._build_url("/v1/playlist-by-path")
|
|
146
|
+
params: list[tuple[str, object]] = [("path", part) for part in playlist_path]
|
|
147
|
+
if playlist_type is not None:
|
|
148
|
+
params.append(("type", playlist_type))
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
response = requests.get(
|
|
152
|
+
endpoint,
|
|
153
|
+
params=params,
|
|
154
|
+
timeout=timeout or self.default_timeout,
|
|
155
|
+
)
|
|
156
|
+
response.raise_for_status()
|
|
157
|
+
payload = response.json() or {}
|
|
158
|
+
except Exception as exc: # noqa: BLE001 - expose networking failures to caller
|
|
159
|
+
self._logger.warning("Could not reach %s: %s", endpoint, exc)
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
data = payload.get("data") if isinstance(payload, dict) else None
|
|
163
|
+
playlist = data.get("playlist") if isinstance(data, dict) else None
|
|
164
|
+
|
|
165
|
+
if isinstance(playlist, dict):
|
|
166
|
+
return playlist
|
|
167
|
+
self._logger.warning("Playlist not found for provided path: %s", playlist_path)
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
#endregion
|
|
171
|
+
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
# Playlist Tools
|
|
174
|
+
# ------------------------------------------------------------------
|
|
175
|
+
#region
|
|
176
|
+
|
|
177
|
+
def _choose_from_list(
|
|
178
|
+
self,
|
|
179
|
+
folder: dict,
|
|
180
|
+
input_func: Callable[[str], str] = input,
|
|
181
|
+
show_counts: bool = False,
|
|
182
|
+
) -> Optional[tuple[list[str], dict]]:
|
|
183
|
+
"""Interactively choose an item within ``folder``.
|
|
184
|
+
|
|
185
|
+
``0`` backs out (or cancels at the root). ``S`` selects the current folder.
|
|
186
|
+
Numbered entries either drill into child folders (type ``1``) or select playlists
|
|
187
|
+
(types ``2``/``3``).
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
if not folder.get("playlists"):
|
|
191
|
+
print("No playlists available to choose from.")
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
stack: list[tuple[dict, list[str]]] = [(folder, [])]
|
|
195
|
+
count_cache: dict[Optional[int], Optional[int]] = {}
|
|
196
|
+
|
|
197
|
+
while stack:
|
|
198
|
+
# Get current folder and path from stack
|
|
199
|
+
current_folder, current_path = stack[-1]
|
|
200
|
+
|
|
201
|
+
# Get folder name and playlists
|
|
202
|
+
current_name = current_folder.get("name", "(unnamed)")
|
|
203
|
+
children = current_folder.get("playlists")
|
|
204
|
+
|
|
205
|
+
# Clear screen between renders
|
|
206
|
+
os.system("cls" if os.name == "nt" else "clear")
|
|
207
|
+
|
|
208
|
+
# Generate track count suffix
|
|
209
|
+
folder_suffix = ""
|
|
210
|
+
if show_counts:
|
|
211
|
+
id = current_folder.get("id")
|
|
212
|
+
if id not in count_cache:
|
|
213
|
+
count_cache[id] = len(set(self.get_playlist(id)["trackIds"]))
|
|
214
|
+
count_val = count_cache.get(id)
|
|
215
|
+
folder_suffix = f" [{count_val if count_val is not None else '--'}]"
|
|
216
|
+
|
|
217
|
+
# Print current folder
|
|
218
|
+
if current_path:
|
|
219
|
+
print(f"{' / '.join(current_path)}{folder_suffix} (Enter)")
|
|
220
|
+
else:
|
|
221
|
+
print(f"{current_name}{folder_suffix} (Enter)")
|
|
222
|
+
|
|
223
|
+
# Print special options
|
|
224
|
+
print(" C. Cancel")
|
|
225
|
+
if len(stack) > 1:
|
|
226
|
+
print(" 0. <- Back")
|
|
227
|
+
|
|
228
|
+
# Print numbered entries
|
|
229
|
+
for idx, item in enumerate(children, start=1):
|
|
230
|
+
# Handles flat lists with "path" keys
|
|
231
|
+
name = " / ".join(item.get("path", [])) or item.get("name", "(unnamed)")
|
|
232
|
+
|
|
233
|
+
# Finds empty folders
|
|
234
|
+
has_children = isinstance(item.get("playlists"), list) and bool(item.get("playlists"))
|
|
235
|
+
|
|
236
|
+
# Determine type
|
|
237
|
+
p_type = int(str(item.get("type", "0")) or 0)
|
|
238
|
+
|
|
239
|
+
# Guide prefixes
|
|
240
|
+
prefix = " "
|
|
241
|
+
if p_type == 1: # Folder
|
|
242
|
+
prefix = " > " if has_children else " - "
|
|
243
|
+
|
|
244
|
+
# Generate track count suffix
|
|
245
|
+
suffix = ""
|
|
246
|
+
if show_counts:
|
|
247
|
+
id = item.get("id")
|
|
248
|
+
if id not in count_cache:
|
|
249
|
+
count_cache[id] = len(self.get_playlist(id)["trackIds"])
|
|
250
|
+
count_val = count_cache.get(id)
|
|
251
|
+
suffix = f" [{count_val if count_val is not None else '--'}]"
|
|
252
|
+
|
|
253
|
+
print(f"{idx:>3}. {prefix}{name}{suffix}")
|
|
254
|
+
|
|
255
|
+
# Prompt for input
|
|
256
|
+
choice = input_func("\nSelect number (Enter: current folder, C: cancel)").strip()
|
|
257
|
+
|
|
258
|
+
# Handle special inputs
|
|
259
|
+
if not choice:
|
|
260
|
+
playlist = self.get_playlist(current_folder.get("id")) # Fetch full details
|
|
261
|
+
return playlist, current_path
|
|
262
|
+
|
|
263
|
+
if choice.lower() == "c":
|
|
264
|
+
print("Selection cancelled.")
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
if choice == "0":
|
|
268
|
+
if len(stack) > 1:
|
|
269
|
+
stack.pop()
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
# Handle invalid numeric input
|
|
273
|
+
try:
|
|
274
|
+
selection = int(choice)
|
|
275
|
+
except ValueError:
|
|
276
|
+
print(f"'{choice}' is not a valid number.")
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
if selection < 1 or selection > len(children):
|
|
280
|
+
print("Selection is out of range.")
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
# Handle list selections
|
|
284
|
+
selected = children[selection - 1]
|
|
285
|
+
|
|
286
|
+
# Determine new path
|
|
287
|
+
selected_name = selected.get("name", "")
|
|
288
|
+
selected_has_path = bool(selected.get("path"))
|
|
289
|
+
if selected_has_path and isinstance(selected.get("path"), list):
|
|
290
|
+
new_path = selected.get("path", []) # Handle flat lists with "path" keys
|
|
291
|
+
else:
|
|
292
|
+
new_path = current_path + [selected_name] # Normal folder navigation
|
|
293
|
+
|
|
294
|
+
# Check type
|
|
295
|
+
selected_type = int(str(selected.get("type", "0")) or 0)
|
|
296
|
+
|
|
297
|
+
# Playlist/Smartlist
|
|
298
|
+
if selected_type in {2, 3}:
|
|
299
|
+
playlist = self.get_playlist(selected.get("id")) # Fetch full details
|
|
300
|
+
return playlist, new_path
|
|
301
|
+
|
|
302
|
+
# Folder
|
|
303
|
+
elif selected_type == 1:
|
|
304
|
+
child_playlists = selected.get("playlists")
|
|
305
|
+
if not isinstance(child_playlists, list) or not child_playlists:
|
|
306
|
+
print("Folder is empty; please choose another entry.")
|
|
307
|
+
continue
|
|
308
|
+
stack.append((selected, new_path))
|
|
309
|
+
|
|
310
|
+
def _flatten_tree(self, tree: dict, base_path=None) -> dict:
|
|
311
|
+
"""Return a shallow copy of ``tree`` with a flat ``playlists`` list.
|
|
312
|
+
|
|
313
|
+
Runs recursively to gather all child playlists/folders into a single list.
|
|
314
|
+
Path is preserved via a ``path`` key on each entry.
|
|
315
|
+
|
|
316
|
+
Each child playlist/folder is cloned removing its own ``playlists`` key and
|
|
317
|
+
receives a ``path`` list showing its ancestors. Folder entries (type ``1``)
|
|
318
|
+
are re-labelled as type ``2`` so they can be treated as selectable by
|
|
319
|
+
``_choose_from_list``.
|
|
320
|
+
"""
|
|
321
|
+
# base_path = list(base_path or []) # Ensures we can append when empty
|
|
322
|
+
flattened = [] # Accumulate all child entries here
|
|
323
|
+
|
|
324
|
+
# Start with a shallow copy of the root without children
|
|
325
|
+
root_dict = {k: v for k, v in tree.items() if k != "playlists"}
|
|
326
|
+
|
|
327
|
+
# Add each child, recursing into folders
|
|
328
|
+
for item in tree.get("playlists") or []:
|
|
329
|
+
# Shallow copy without children
|
|
330
|
+
cloned = {k: v for k, v in item.items() if k != "playlists"}
|
|
331
|
+
|
|
332
|
+
# Determine and set path
|
|
333
|
+
item_name = item.get("name", "")
|
|
334
|
+
item_path = (base_path + [item_name]) if base_path else [item_name]
|
|
335
|
+
cloned["path"] = item_path
|
|
336
|
+
|
|
337
|
+
# Re-label folders as selectable playlists
|
|
338
|
+
if str(item.get("type")) == "1":
|
|
339
|
+
cloned["type"] = "2"
|
|
340
|
+
|
|
341
|
+
# Add to flat list
|
|
342
|
+
flattened.append(cloned)
|
|
343
|
+
|
|
344
|
+
# Recurse into folders
|
|
345
|
+
if str(item.get("type")) == "1":
|
|
346
|
+
flattened.extend(self._flatten_tree(item, item_path)["playlists"])
|
|
347
|
+
|
|
348
|
+
# Add flat list to root copy and return
|
|
349
|
+
root_dict["playlists"] = flattened
|
|
350
|
+
return root_dict
|
|
351
|
+
|
|
352
|
+
def choose_playlist(
|
|
353
|
+
self,
|
|
354
|
+
*,
|
|
355
|
+
flat: bool = False,
|
|
356
|
+
show_counts: bool = True,
|
|
357
|
+
timeout: Optional[int] = None,
|
|
358
|
+
input_func: Callable[[str], str] = input,
|
|
359
|
+
) -> Optional[tuple[dict, list[str]]]:
|
|
360
|
+
"""Fetch playlists and interactively choose one via stdin.
|
|
361
|
+
|
|
362
|
+
Returns a tuple of (playlist_dict, path) or ``None`` if the user cancels.
|
|
363
|
+
|
|
364
|
+
Parameters
|
|
365
|
+
----------
|
|
366
|
+
flat:
|
|
367
|
+
When ``True`` presents a flattened list instead of navigating folders. Names are the full path.
|
|
368
|
+
show_counts:
|
|
369
|
+
When ``True`` (default) displays track counts by fetching each playlist's
|
|
370
|
+
metadata; set to ``False`` to skip the extra API calls.
|
|
371
|
+
"""
|
|
372
|
+
playlists = self.get_playlists(timeout=timeout)
|
|
373
|
+
|
|
374
|
+
if not playlists:
|
|
375
|
+
self._logger.warning("Unable to fetch playlists from Lexicon.")
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
if not flat:
|
|
379
|
+
input = playlists
|
|
380
|
+
# print("Browsing playlist tree...\n")
|
|
381
|
+
else:
|
|
382
|
+
input = self._flatten_tree(playlists)
|
|
383
|
+
# print("All playlists shown...\n")
|
|
384
|
+
|
|
385
|
+
selection = self._choose_from_list(
|
|
386
|
+
input,
|
|
387
|
+
input_func=input_func,
|
|
388
|
+
show_counts=show_counts,
|
|
389
|
+
)
|
|
390
|
+
if selection is None:
|
|
391
|
+
self._logger.info("No playlist selected.")
|
|
392
|
+
return selection
|
|
393
|
+
|
|
394
|
+
#endregion
|
|
395
|
+
|
|
396
|
+
# ------------------------------------------------------------------
|
|
397
|
+
# Track API Wrappers
|
|
398
|
+
# - See https://www.lexicondj.com/docs/developers/api
|
|
399
|
+
# ------------------------------------------------------------------
|
|
400
|
+
#region
|
|
401
|
+
|
|
402
|
+
def get_track(self, track_id: int, *, timeout: Optional[int] = None) -> dict | None:
|
|
403
|
+
"""Fetch a single track's full info from Lexicon.
|
|
404
|
+
Via the ``/v1/track`` endpoint.
|
|
405
|
+
"""
|
|
406
|
+
endpoint = self._build_url("/v1/track")
|
|
407
|
+
try:
|
|
408
|
+
response = requests.get(
|
|
409
|
+
endpoint,
|
|
410
|
+
params={"id": track_id},
|
|
411
|
+
timeout=timeout or self.default_timeout,
|
|
412
|
+
)
|
|
413
|
+
response.raise_for_status()
|
|
414
|
+
payload = response.json() or {}
|
|
415
|
+
except Exception as exc: # noqa: BLE001 - expose networking failures to caller
|
|
416
|
+
self._logger.warning("Could not fetch track %s: %s", track_id, exc)
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
data = payload.get("data") if isinstance(payload, dict) else None
|
|
420
|
+
track = data.get("track") if isinstance(data, dict) else None
|
|
421
|
+
|
|
422
|
+
if isinstance(track, dict):
|
|
423
|
+
return track
|
|
424
|
+
self._logger.warning("Track %s not found in response", track_id)
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
def get_tracks(
|
|
428
|
+
self,
|
|
429
|
+
*,
|
|
430
|
+
limit: Optional[int] = 1000,
|
|
431
|
+
offset: Optional[int] = 0,
|
|
432
|
+
source: Optional[str] = "non-archived",
|
|
433
|
+
fields: Optional[Sequence[str]] = None,
|
|
434
|
+
sort: Optional[Sequence[tuple[str, str | None]]] = None,
|
|
435
|
+
timeout: Optional[int] = None,
|
|
436
|
+
get_all: bool = False,
|
|
437
|
+
) -> list[dict]:
|
|
438
|
+
"""
|
|
439
|
+
Fetch all tracks via the ``/v1/tracks`` endpoint.
|
|
440
|
+
|
|
441
|
+
Parameters
|
|
442
|
+
----------
|
|
443
|
+
limit / offset:
|
|
444
|
+
Paging controls. Values below zero are clamped to zero.
|
|
445
|
+
source:
|
|
446
|
+
One of :meth:`available_track_sources`.
|
|
447
|
+
fields:
|
|
448
|
+
Iterable of field names to include. Use :meth:`available_track_fields`.
|
|
449
|
+
sort:
|
|
450
|
+
Temporarily unused until the upstream API clarifies the expected
|
|
451
|
+
serialization. Any values supplied are currently ignored.
|
|
452
|
+
get_all:
|
|
453
|
+
When ``True``, fetches all pages of results. Otherwise only the first
|
|
454
|
+
page is returned.
|
|
455
|
+
"""
|
|
456
|
+
|
|
457
|
+
endpoint = self._build_url("/v1/tracks")
|
|
458
|
+
params: list[tuple[str, object]] = []
|
|
459
|
+
|
|
460
|
+
if limit is not None:
|
|
461
|
+
params.append(("limit", max(int(limit), 0)))
|
|
462
|
+
if source:
|
|
463
|
+
params.append(("source", source))
|
|
464
|
+
if fields:
|
|
465
|
+
params.extend(("fields", field) for field in fields)
|
|
466
|
+
|
|
467
|
+
if sort:
|
|
468
|
+
self._logger.warning(
|
|
469
|
+
"Track sorting is temporarily disabled pending clarified API docs; ignoring provided sort=%s",
|
|
470
|
+
sort,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
collected: list[dict] = []
|
|
474
|
+
next_offset = max(int(offset or 0), 0)
|
|
475
|
+
total_remaining = None
|
|
476
|
+
|
|
477
|
+
while total_remaining is None or (total_remaining > 0 and get_all):
|
|
478
|
+
page_params = list(params)
|
|
479
|
+
page_params.append(("offset", next_offset))
|
|
480
|
+
print(page_params) # DEBUG
|
|
481
|
+
|
|
482
|
+
try:
|
|
483
|
+
response = requests.get(
|
|
484
|
+
endpoint,
|
|
485
|
+
params=page_params,
|
|
486
|
+
timeout=timeout or self.default_timeout,
|
|
487
|
+
)
|
|
488
|
+
response.raise_for_status()
|
|
489
|
+
payload = response.json() or {}
|
|
490
|
+
except Exception as exc: # noqa: BLE001 - expose networking failures to caller
|
|
491
|
+
self._logger.warning("Could not fetch tracks from %s: %s", endpoint, exc)
|
|
492
|
+
break
|
|
493
|
+
|
|
494
|
+
data = payload.get("data") if isinstance(payload, dict) else None
|
|
495
|
+
tracks = data.get("tracks") if isinstance(data, dict) else None
|
|
496
|
+
|
|
497
|
+
if isinstance(tracks, list):
|
|
498
|
+
collected.extend(tracks)
|
|
499
|
+
else:
|
|
500
|
+
self._logger.warning(
|
|
501
|
+
"Tracks response missing expected list; parameters were %s",
|
|
502
|
+
page_params,
|
|
503
|
+
)
|
|
504
|
+
break
|
|
505
|
+
|
|
506
|
+
# Handle paging
|
|
507
|
+
total = data.get("total") if isinstance(data, dict) else None
|
|
508
|
+
page_limit = data.get("limit") if isinstance(data, dict) else None
|
|
509
|
+
if isinstance(total, int) and isinstance(page_limit, int):
|
|
510
|
+
if total_remaining:
|
|
511
|
+
total_remaining -= page_limit
|
|
512
|
+
else:
|
|
513
|
+
total_remaining = total - page_limit
|
|
514
|
+
next_offset += page_limit
|
|
515
|
+
else:
|
|
516
|
+
self._logger.warning(
|
|
517
|
+
"Tracks response missing expected total/limit; cannot page further. Returning first page only."
|
|
518
|
+
)
|
|
519
|
+
break # Can't page without total/limit info
|
|
520
|
+
|
|
521
|
+
return collected
|
|
522
|
+
|
|
523
|
+
def search_tracks(
|
|
524
|
+
self,
|
|
525
|
+
filter: dict,
|
|
526
|
+
*,
|
|
527
|
+
source: Optional[str] = "non-archived",
|
|
528
|
+
fields: Optional[Sequence[str]] = None,
|
|
529
|
+
sort: Optional[Sequence[tuple[str, str | None]]] = None,
|
|
530
|
+
timeout: Optional[int] = None,
|
|
531
|
+
) -> list[dict] | None:
|
|
532
|
+
"""
|
|
533
|
+
Search for tracks via the ``/v1/search/tracks`` endpoint.
|
|
534
|
+
Limited to 1000 results.
|
|
535
|
+
|
|
536
|
+
Parameters
|
|
537
|
+
----------
|
|
538
|
+
filter:
|
|
539
|
+
Filter dictionary.
|
|
540
|
+
Keys are search fields, values are the search terms.
|
|
541
|
+
Use :meth:`available_track_fields` for valid keys.
|
|
542
|
+
source:
|
|
543
|
+
One of :meth:`available_track_sources`.
|
|
544
|
+
fields:
|
|
545
|
+
Iterable of field names to include in response. Use :meth:`available_track_fields`.
|
|
546
|
+
sort:
|
|
547
|
+
Temporarily unused until the upstream API clarifies the expected
|
|
548
|
+
serialization. Any values supplied are currently ignored.
|
|
549
|
+
"""
|
|
550
|
+
|
|
551
|
+
endpoint = self._build_url("/v1/search/tracks")
|
|
552
|
+
params: list[tuple[str, object]] = []
|
|
553
|
+
|
|
554
|
+
if isinstance(filter, dict):
|
|
555
|
+
for key, value in filter.items():
|
|
556
|
+
if key in TRACK_FIELDS:
|
|
557
|
+
params.append((f"filter[{key}]", value))
|
|
558
|
+
else:
|
|
559
|
+
self._logger.warning("Ignoring invalid track filter field: %s", key)
|
|
560
|
+
if source:
|
|
561
|
+
params.append(("source", source))
|
|
562
|
+
if fields:
|
|
563
|
+
params.extend(("fields", field) for field in fields)
|
|
564
|
+
|
|
565
|
+
if sort:
|
|
566
|
+
self._logger.warning(
|
|
567
|
+
"Track sorting is temporarily disabled pending clarified API docs; ignoring provided sort=%s",
|
|
568
|
+
sort,
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
try:
|
|
572
|
+
response = requests.get(
|
|
573
|
+
endpoint,
|
|
574
|
+
params=params,
|
|
575
|
+
timeout=timeout or self.default_timeout,
|
|
576
|
+
)
|
|
577
|
+
response.raise_for_status()
|
|
578
|
+
payload = response.json() or {}
|
|
579
|
+
except Exception as exc: # noqa: BLE001 - expose networking failures to caller
|
|
580
|
+
self._logger.warning("Could not fetch tracks from %s: %s", endpoint, exc)
|
|
581
|
+
return None
|
|
582
|
+
|
|
583
|
+
data = payload.get("data") if isinstance(payload, dict) else None
|
|
584
|
+
tracks = data.get("tracks") if isinstance(data, dict) else None
|
|
585
|
+
|
|
586
|
+
total = data.get("total") if isinstance(data, dict) else None
|
|
587
|
+
if isinstance(tracks, list):
|
|
588
|
+
num_tracks = len(tracks)
|
|
589
|
+
if isinstance(total, int) and total > num_tracks:
|
|
590
|
+
self._logger.warning(
|
|
591
|
+
"Search matched %s total tracks but only %s were returned; consider narrowing search terms",
|
|
592
|
+
total,
|
|
593
|
+
num_tracks,
|
|
594
|
+
)
|
|
595
|
+
return tracks
|
|
596
|
+
else:
|
|
597
|
+
self._logger.warning(
|
|
598
|
+
"Tracks response missing expected list; parameters were %s",
|
|
599
|
+
params,
|
|
600
|
+
)
|
|
601
|
+
return None
|
|
602
|
+
|
|
603
|
+
#endregion
|
|
604
|
+
|
|
605
|
+
# ------------------------------------------------------------------
|
|
606
|
+
# Track Tools
|
|
607
|
+
# ------------------------------------------------------------------
|
|
608
|
+
#region
|
|
609
|
+
|
|
610
|
+
def get_track_batch(
|
|
611
|
+
self,
|
|
612
|
+
track_ids: Iterable[int],
|
|
613
|
+
*,
|
|
614
|
+
max_workers: int = 5,
|
|
615
|
+
timeout: Optional[int] = None,
|
|
616
|
+
) -> list[dict]:
|
|
617
|
+
"""
|
|
618
|
+
Fetch metadata for a collection of tracks.
|
|
619
|
+
|
|
620
|
+
Defaults to making 5 requests in parallel.
|
|
621
|
+
Set ``max_workers=0`` to fetch one at a time.
|
|
622
|
+
More than 5 workers doesn't seem to improve speed but results may vary.
|
|
623
|
+
"""
|
|
624
|
+
track_ids = list(track_ids)
|
|
625
|
+
results: list[dict] = []
|
|
626
|
+
|
|
627
|
+
if not track_ids:
|
|
628
|
+
return results
|
|
629
|
+
|
|
630
|
+
effective_timeout = timeout or self.default_timeout
|
|
631
|
+
|
|
632
|
+
if max_workers == 0:
|
|
633
|
+
for track_id in tqdm(track_ids, desc="Fetching tracks", unit=" tracks"):
|
|
634
|
+
info = self.get_track(track_id, timeout=effective_timeout)
|
|
635
|
+
if info:
|
|
636
|
+
results.append(info)
|
|
637
|
+
return results
|
|
638
|
+
|
|
639
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
640
|
+
futures = {
|
|
641
|
+
executor.submit(self.get_track, track_id, timeout=effective_timeout): track_id
|
|
642
|
+
for track_id in track_ids
|
|
643
|
+
}
|
|
644
|
+
with tqdm(total=len(futures), desc="Fetching tracks (parallel)", unit=" tracks") as pbar:
|
|
645
|
+
for future in as_completed(futures):
|
|
646
|
+
track_id = futures[future]
|
|
647
|
+
try:
|
|
648
|
+
info = future.result()
|
|
649
|
+
if info:
|
|
650
|
+
results.append(info)
|
|
651
|
+
except Exception as exc: # noqa: BLE001 - handle worker failures gracefully
|
|
652
|
+
self._logger.warning("Track %s failed during fetch: %s", track_id, exc)
|
|
653
|
+
finally:
|
|
654
|
+
pbar.update(1)
|
|
655
|
+
|
|
656
|
+
return results
|
|
657
|
+
|
|
658
|
+
#endregion
|
|
659
|
+
|
|
660
|
+
# ------------------------------------------------------------------
|
|
661
|
+
# Tag API Wrappers
|
|
662
|
+
# - See https://www.lexicondj.com/docs/developers/api
|
|
663
|
+
# ------------------------------------------------------------------
|
|
664
|
+
#region
|
|
665
|
+
|
|
666
|
+
def get_tags(self, *, timeout: Optional[int] = None) -> dict | None:
|
|
667
|
+
"""Fetch all tags via the ``/v1/tags`` endpoint."""
|
|
668
|
+
endpoint = self._build_url("/v1/tags")
|
|
669
|
+
try:
|
|
670
|
+
response = requests.get(
|
|
671
|
+
endpoint,
|
|
672
|
+
timeout=timeout or self.default_timeout,
|
|
673
|
+
)
|
|
674
|
+
response.raise_for_status()
|
|
675
|
+
payload = response.json() or {}
|
|
676
|
+
except Exception as exc: # noqa: BLE001 - expose networking failures to caller
|
|
677
|
+
self._logger.warning("Could not reach %s: %s", endpoint, exc)
|
|
678
|
+
return None
|
|
679
|
+
|
|
680
|
+
data = payload.get("data") if isinstance(payload, dict) else None
|
|
681
|
+
if isinstance(data, dict) and (isinstance(data.get("categories"), list) or isinstance(data.get("tags"), list)):
|
|
682
|
+
return data
|
|
683
|
+
|
|
684
|
+
self._logger.warning("Response did not contain expected tags structure")
|
|
685
|
+
return None
|
|
686
|
+
|
|
687
|
+
__all__ = [
|
|
688
|
+
"DEFAULT_HOST",
|
|
689
|
+
"LEXICON_PORT",
|
|
690
|
+
"LexiconClient",
|
|
691
|
+
]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lexicon-python
|
|
3
|
+
Version: 0.1.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.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: requests>=2.31
|
|
12
|
+
Requires-Dist: tqdm>=4.0
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# lexicon-python
|
|
16
|
+
|
|
17
|
+
A lightweight Python client for the [Lexicon DJ](https://www.lexicondj.com/) API. It wraps the REST endpoints used for playlist browsing and track lookups, while staying simple enough to embed inside scripts or larger automation projects.
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- Class `LexiconClient` with configurable host/port.
|
|
22
|
+
- All GET requests for Playlists, Tracks, and Tags available
|
|
23
|
+
- Handy helpers:
|
|
24
|
+
- Interactive `choose_playlist` prompt for fast CLI workflows.
|
|
25
|
+
- GET for all tracks can automatically retrieve all pages with `get_all`
|
|
26
|
+
- Batch function `get_track_batch` can retrieve full metadata for a list of tracks (including progress bar
|
|
27
|
+
for large retrievals)
|
|
28
|
+
- Minimal dependencies (`requests`, `tqdm`) and a pure-Python implementation suitable for scripts or larger apps.
|
|
29
|
+
|
|
30
|
+
## Quickstart
|
|
31
|
+
|
|
32
|
+
1. Create a virtual environment and install requirements:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
python3 -m venv .venv
|
|
36
|
+
source .venv/bin/activate
|
|
37
|
+
pip install -r requirements.txt
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
2. Run the example script (ensure your Lexicon instance is reachable):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
python examples/demo_lexicon.py
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The script prompts you to choose a playlist, then fetches metadata for the first five tracks.
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from lexicon import LexiconClient
|
|
52
|
+
|
|
53
|
+
lexicon = LexiconClient()
|
|
54
|
+
|
|
55
|
+
# Choose playlist interactively and fetch it's tracks
|
|
56
|
+
selection = lexicon.choose_playlist(show_counts=True)
|
|
57
|
+
if selection:
|
|
58
|
+
path, playlist = selection
|
|
59
|
+
print("Selected:", " / ".join(path))
|
|
60
|
+
track_ids = set(playlist.get("trackIds", []))
|
|
61
|
+
print("Tracks reported: ", len(track_ids))
|
|
62
|
+
|
|
63
|
+
tracks = lexicon.get_track_batch(track_ids, max_workers=5)
|
|
64
|
+
for track in tracks:
|
|
65
|
+
print(track["title"], "-", track["artist"])
|
|
66
|
+
else:
|
|
67
|
+
print("No playlist selected.")
|
|
68
|
+
|
|
69
|
+
# Fetch the complete library in chunks of 250
|
|
70
|
+
tracks = lexicon.get_tracks(limit=250, get_all=True) or []
|
|
71
|
+
print(f"Fetched {len(tracks)} tracks")
|
|
72
|
+
|
|
73
|
+
# Search within your library
|
|
74
|
+
results = lexicon.search_tracks({"artist": "Daft Punk", "bpm": ">=120"}) or []
|
|
75
|
+
print(f"Found {len(results)} matching tracks")
|
|
76
|
+
|
|
77
|
+
# Inspect tags and categories
|
|
78
|
+
tags_payload = lexicon.get_tags()
|
|
79
|
+
if tags_payload:
|
|
80
|
+
for category in tags_payload["categories"]:
|
|
81
|
+
print("Category:", category["label"])
|
|
82
|
+
for tag_id in category["tags"]:
|
|
83
|
+
tag = next((t for t in tags_payload["tags"] if t["id"] == tag_id), None)
|
|
84
|
+
print("->", tag["label"])
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
See `examples/demo_lexicon.py` for a more complete walkthrough.
|
|
88
|
+
|
|
89
|
+
## Development
|
|
90
|
+
|
|
91
|
+
- Run the test suite: `PYTHONPATH=src python -m unittest discover -s tests`
|
|
92
|
+
- Style: keep the package pure Python, logging via `logging.getLogger(__name__)`, and prefer small, testable helpers.
|
|
93
|
+
- Packaging metadata lives in `pyproject.toml` (see below).
|
|
94
|
+
|
|
95
|
+
Contributions welcome—open an issue or PR with ideas!
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
lexicon/__init__.py,sha256=0z6MEDbb3ET9jukDbaq1yd5eH6eND8U4HvdlVhL_UYE,125
|
|
2
|
+
lexicon/lexicon.py,sha256=GbQjOR9vZZ1h21O06JRZqCiWl1UKj_6kwV8US1xsiyo,26512
|
|
3
|
+
lexicon_python-0.1.0.dist-info/licenses/LICENSE,sha256=SnZvUu04t9ENhnrmuLMoz2E6pAYcEIX8UGBl0P4D43c,1072
|
|
4
|
+
lexicon_python-0.1.0.dist-info/METADATA,sha256=I361yXdac7Q3UeRxcyRE3z8k8KouQk6fSwfr4csmmSw,3240
|
|
5
|
+
lexicon_python-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
6
|
+
lexicon_python-0.1.0.dist-info/top_level.txt,sha256=Ry_712VYZowp819R6r0kTIdSPW_kSEKdC_k6MVSYvYo,8
|
|
7
|
+
lexicon_python-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Garrison Burger
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
lexicon
|