Python client for the Lexicon DJ Local API.
This SDK wraps the Lexicon Local API with resource groups, sensible defaults, and optional validation. It is designed for scripting library automation, playlist management, and metadata edits while keeping a clean escape hatch to the raw API.
- Resource grouped client (
lex.tracks,lex.playlists,lex.tags) - Pagination handled for
tracks.list()(API returns 1000 per page) - Validation modes for inputs: warn (default), strict, or off
- Typed responses and payload hints (TypedDicts and Literals)
- Optional interactive playlist chooser via
InquirerPy - Raw request escape hatch via
lex.request(...)
- Python 3.9+
- Lexicon DJ running with Local API enabled
pip install lexicon-pythonfrom lexicon import Lexicon
lex = Lexicon()
# list tracks (default fields)
tracks = lex.tracks.list(limit=10) or []
for t in tracks:
print(t.get("artist"), "-", t.get("title"))
# search
results = lex.tracks.search({"artist": "Daft Punk"}) or []
print("matches:", len(results))
# get a playlist by path
playlist = lex.playlists.get_by_path(["Genres", "Drum & Bass"], playlist_type="folder")
print(playlist)By default the client targets:
- Host:
localhost - Port:
48624
You can override these via constructor args or environment variables.
lex = Lexicon(host="127.0.0.1", port=48624, raise_on_error=False)Other options:
default_timeout: request timeout in secondssession: optionalrequests.Sessionraise_on_error: raise HTTP errors instead of returning None
Environment variables:
export LEXICON_HOST=localhost
export LEXICON_PORT=48624Many methods accept a validation parameter with three modes:
"warn"(default): invalid inputs are skipped with a warning. This avoids API failures, but intended changes may be ignored."strict": invalid inputs raiseValueError."off": skips normalization and sends inputs as-is (inputs must match API-native shapes)
Example:
lex.tracks.search({"rating": "bad"}, validation="warn") # logs warning
lex.tracks.search({"rating": "bad"}, validation="strict") # raises
lex.tracks.search({"rating": "bad"}, validation="off") # sends as-isThis SDK adds several quality-of-life behaviors on top of the raw Lexicon API.
In general, response shapes are unwrapped (e.g., "data": {...} is removed and
single-item lists are collapsed to a single dict).
tracks.get_many()repeatsget()and preserves input order.playlists.get_many()repeatsget()and preserves input order.playlists.tracks.get()fetches track dicts in playlist order.playlists.tracks.update()replaces the full track list (remove + add).playlists.choose()provides an interactive chooser (wraps list + get).playlists.get_path()resolves a path from a playlist tree.
- ID normalization: Many methods accept lists of IDs instead of a single ID for easier batch operations.
- Field selection: By default,
tracks.list()andtracks.search()return a minimal set of fields rather than the full payload:id,artist,title,albumTitle,bpm,key,duration,year- Fields used as a search filter or sort item are also returned.
- Search filter normalization:
- Text fields accept
None(becomes"NONE"in filter context). - Numeric filters accept
None(becomes"0"). - Date filters accept
YYYY-MM-DD, full datetime strings, ordatetime.date/datetime.datetimeinputs (time is stripped).- Comparisons (
>YYYY-MM-DD) are warned/blocked because the API currently ignores them.
- Comparisons (
- Text fields accept
- Sort normalization:
- Accepts tuple shorthand:
[("title", "asc")].
- Accepts tuple shorthand:
- Track update helpers:
- Cuepoint and tempomarker entries are normalized (e.g., cuepoint type accepts name/number variants).
- Invalid entries can be skipped in
"warn"mode without failing the update.
If no SDK normalization is desired, use validation="off" and pass
API-native payloads. API-native shapes are also accepted in "warn"/"strict";
those modes simply add normalization/validation on top. For fully raw access,
lex.request(...) can always be called directly.
Common operations:
track = lex.tracks.get(123)
tracks = lex.tracks.get_many([1, 2, 3])
tracks = lex.tracks.list(limit=100)
tracks = lex.tracks.search({"artist": "Daft Punk"})
added = lex.tracks.add(["/path/to/file1.mp3", "/path/to/file2.mp3"])
updated = lex.tracks.update(123, {"title": "New Title"})
lex.tracks.delete([123, 456])Notes:
tracks.search()results are capped at 1000 by the API.tracks.get_many()preserves input order and returnsNonefor missing IDs.fields=Nonereturns a minimal default set of fields.- In
validation="off"mode,fields=Nonereturns full payloads (API-default)
- In
fields="all"orfields="*"requests full payloads.tracks.add()returns track dicts, but analysis fields (tempo markers, key, etc.) may be populated later by Lexicon.
tracks.search(filter=...) accepts a dict of field names and values. The SDK
validates fields/values in "warn"/"strict" modes and can send API-native values
in "off" mode.
Examples:
# text filters
lex.tracks.search({"artist": "Daft Punk"})
# numeric filters (strings)
lex.tracks.search({"bpm": "120"})
lex.tracks.search({"bpm": "120-128"})
lex.tracks.search({"bpm": ">=120"})
# date filters (YYYY-MM-DD)
lex.tracks.search({"dateAdded": "2024-01-01"})
# tag filters (comma-separated names)
# - default: OR across tags
# - prefix with "~" to require ALL tags (AND)
# - prefix with "!" to exclude a tag
lex.tracks.search({"tags": "Rock, Chill"}) # Rock OR Chill
lex.tracks.search({"tags": "~Rock, Chill"}) # Rock AND Chill
lex.tracks.search({"tags": "~Rock, !Chill"}) # Rock AND NOT ChillTag filter details:
- Input is a single string with comma-separated tag names.
- Whitespace is ignored around commas.
~at the start switches from OR to AND for the list.!before a tag name negates that tag.- There is no supported way to search for “no tags”;
NONEis not accepted.
Sort can be expressed in two shapes:
- API-native: list of dicts:
[{"field": "title", "dir": "asc"}] - Alternative: list of tuples:
[("title", "asc")]
API-native dicts work in all modes; validation="off" requires the dict shape.
playlist = lex.playlists.get(42)
playlist = lex.playlists.get_by_path(["Genres", "Drum & Bass"], playlist_type="playlist")
tree = lex.playlists.list()
new_id = lex.playlists.add("Demo Playlist", playlist_type="playlist", parent_id=1)
lex.playlists.update(new_id, name="Renamed Playlist")
lex.playlists.delete([new_id])Playlist type accepts:
"folder","playlist","smartlist"1,2,3(or string numerals"1","2","3")
For getting the tracks of a playlist or editing the tracklist.
track_ids = lex.playlists.tracks.list(42)
tracks = lex.playlists.tracks.get(42)
lex.playlists.tracks.add(42, [1, 2, 3])
lex.playlists.tracks.remove(42, [1, 2])
lex.playlists.tracks.update(42, [3, 2, 1])tags = lex.tags.list()
new_tag = lex.tags.add(category_id=1, label="Demo Tag")
lex.tags.update(new_tag["id"], label="Renamed Tag")
lex.tags.delete(new_tag["id"])
categories = lex.tags.categories.list()
new_cat = lex.tags.categories.add(label="Demo Category", color="red")
lex.tags.categories.update(new_cat["id"], label="Renamed Category")
lex.tags.categories.delete(new_cat["id"])Interactive playlist chooser (requires InquirerPy):
choice = lex.playlists.choose()
print(choice)
# Or avoid an API call if you already have the tree
playlist_tree = lex.playlists.list()
choice = lex.tools.playlists.choose_playlist(playlist_tree) if playlist_tree else None
print(choice)Helper to resolve a playlist path from a tree:
path = lex.playlists.get_path(42)
print(path)
# Or avoid an API call if you already have the tree
playlist_tree = lex.playlists.list()
path = lex.tools.playlists.get_path_from_tree(playlist_tree, playlist_id=42)
print(path)
# -> ["Genres", "Drum & Bass"]The API can always be accessed directly with lex.request:
payload = lex.request("GET", "/tracks", params={"fields": "all"})High level namespaces (full mapping in docs/resource-map.md):
lex.tracks: get, get_many, list, search, add, update, deletelex.playlists: get, get_many, list (tree root), get_path, get_by_path, add, update, delete, chooselex.playlists.tracks: list (IDs), get (track dicts), add, remove, updatelex.tags: list, add, update, deletelex.tags.categories: list, add, update, delete
The SDK includes TypedDict and Literal types for payloads and enums. These are intended to improve editor autocomplete and static checks.
For full payload schemas and endpoint details, refer to the Lexicon API docs:
Ensure that you have Python 3.9+ installed locally. To install all runtime and dev dependencies into a local virtual environment using pip:
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"To use uv to install all runtime and dev dependencies into a local virtual environment, simply install uv and run:
uv sync --devThe lockfile (uv.lock) is checked in to ensure reproducible installs.
# Unit tests
make run-tests
# Integration tests (requires Lexicon running)
# Note: Integration tests enforce an empty library state to avoid destructive edits on existing libraries.
# The fixture setup will back up the existing library, clear it for testing, and restore it afterward.
make run-integration-testsThe project uses ruff for both linting and formatting.
make test runs the full suite: format, lint (with auto-fix), then tests.
make fix runs all auto-fixers (lint + format) without running tests.
make test # format-fix → lint-fix → format-check → lint-check → tests
make fix # lint-fix → format-fix
make clean # remove __pycache__, .pytest_cache, .ruff_cache, etc.If you want to run the linters or formatters manually, you can use the following commands:
make lint-check # check for lint issues
make lint-fix # auto-fix lint issues
make format-check # check formatting
make format-fix # auto-fix formattingGitHub Actions runs on every push to main and on pull requests
targeting those branches. The pipeline includes:
- Tests across Python 3.9, 3.10, 3.11, and 3.12
- Lint and format checks via ruff on Python 3.12
Before opening a PR, make sure make test passes locally.
A PR template is provided at .github/pull_request_template.md. When opening a
PR, fill in the description, check the relevant change-type boxes, and confirm
testing/checklist items.
MIT (see LICENSE).