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
@@ -0,0 +1,5 @@
1
+ """Public package surface for the lexicon Python client."""
2
+
3
+ from .lexicon import LexiconClient
4
+
5
+ __all__ = ["LexiconClient"]
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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