plex-axi
An Agent eXperience Interface (AXI) CLI for a Plex music library.
Structured, per-field music search. Every published Plex CLI, MCP server and LLM wrapper takes a
single free-text query string and hands it to the server as one blob — which is why searching for
an artist and a song title reliably returns nothing. plex-axi gives each value its own flag and
each flag its own Plex field, and lets Plex evaluate the whole predicate server-side.
$ plex-axi search --artist "Example Artist" --track "Example Track"
count: 1 of 1 total
grouped: title
filters[2]{field,operator,value}:
artist.title,contains,Example Artist
track.title,contains,Example Track
tracks[1]{key,title,artist,album}:
111,Example Track,Example Artist,Example Album
item:
media_id: "plex://<machineIdentifier>/111"
rating_key: 111
guid: "plex://track/a1b2c3d4e5f60718293c0111"
note: "rating_key is local to this server and changes when an item is re-matched or the library is rebuilt; guid is the identifier that survives, so keep them together"
It also exposes the parts of Plex's music surface that nothing else does: the library's own genre,
mood and style vocabularies; sonic similarity with the server's own distance; the music-analysis
version behind an empty "more like this"; and, behind a flag, whether the server can actually read
the file.
It deliberately does not play anything. There is no play command and no concept of a speaker,
room, player or client. Every command ends at a labelled media_id, and dispatch belongs to
whatever owns the speakers where you are. That is a decision enforced by a test, not a missing
feature — see AGENTS.md.
Two commands can change your library, and neither runs by accident. rate sets the rating that
--rated-min reads, and playlist edits an audio playlist. Both are refused unless the operator
has exported PLEX_AXI_ALLOW_WRITES=true, and even then they preview the change and send nothing
until --write is passed. Every other command reads. See Writing.
Install
pipx install plex-axi # or: uv tool install plex-axi
Or run it without installing:
uvx plex-axi search --artist "Example Artist"
pipx run plex-axi genres
Configure
Both values come from the environment. There is no --token flag and no credential file: a
token on a command line leaks into shell history and the process table, and a Plex token is a bearer
credential for an entire library.
export PLEX_URL=http://plex.example.com:32400 # the server on your local network
export PLEX_TOKEN=<a Plex access token>
export PLEX_SECTION='Example Music' # only if the server has more than one
export PLEX_AXI_ALLOW_WRITES=true # only if `rate` and `playlist` may write
Point PLEX_URL at the server itself rather than at plex.tv, so the tool keeps working when plex.tv
is unreachable and no invocation pays a cloud round-trip. Finding a token is documented at
https://support.plex.tv/articles/204059436.
plex-axi doctor # exits non-zero when any check fails, so it works as a hook or CI gate
Use
plex-axi # the library at a glance
plex-axi search --artist "Example Artist" --rated-min 4
plex-axi search --genre Jazz --type album --limit 10
plex-axi pick --rated-min 4 --not-played-since 30d --exclude-live
plex-axi genres # and `moods`, `styles`
plex-axi track 12345 --check-files
plex-axi similar 12345 --max-distance 0.1
plex-axi recent
plex-axi playlist show "Example Playlist"
plex-axi sessions
plex-axi api /library/sections # the escape hatch, GET only
Every command takes --help, which is the authoritative reference for its flags — including an
access: line under the description saying whether that command reads or writes.
pick is the "give me something to listen to" command. Every one of its filters is a Plex
predicate the server evaluates — the rating comparison, the genre, the relative date, the
compilation/live exclusion — and the shuffle is Plex's own sort=random over the whole match set
rather than a shuffle of one page. A filter this particular server does not offer is reported under
unapplied rather than quietly applied in Python, because a client-side filter fights --limit
exactly as it fights Plex's own limit.
Reading as another account
Ratings and playlists are per account, so --user <plex-username> answers the same questions for
somebody else on the server:
plex-axi --user example-friend playlist
It is admin only — the per-user tokens are the owner's to read — and it is the one flag here that needs a round-trip to plex.tv, because the mapping from a username to that user's token for this server exists nowhere else. Everything else keeps working with plex.tv down, and a failure here says which of the two happened rather than arriving as an unexplained 401.
Output
TOON on stdout, exit non-zero on failure. --human for a readable table, --json for raw JSON.
Errors are structured on stdout too and carry the command that fixes them, so a wrong flag corrects
itself in one turn.
Rules of thumb
- Use a flag per field.
--artist X --track Ysearches two Plex fields;--query "X Y"searches one string.--queryexists for the case where there genuinely is only one unstructured string, and its--helpsays so. - Ratings are stars, 0–5, in both directions. A rating printed in a result can be passed straight
back to
--rated-min. - Genres and styles live on the artist, not the track — that is how Plex tags a music library.
plex-axi genresprints the exact strings the server will accept; pass one of those, not a synonym. - A zero result is an answer. It names the filters that matched nothing and the command that lists the real vocabulary.
rating_keyis local to one server and moves when an item is re-matched or the library is rebuilt. Keep theguidbeside it anywhere the value is written down.- A mutating command without
--writeis a useful command, not a nag. It prints exactly what would change and sends nothing, which is how to check a playlist edit before making it — and how a smart playlist is caught before the server would refuse it.
Writing
plex-axi began as a read-only tool and most of it still is. Two commands are not:
| Command | What it changes |
|---|---|
rate <rating_key> --stars <0-5> |
your rating on one track, album or artist — per account, not library metadata |
playlist create|add|remove |
the contents of an audio playlist |
Both are behind the same two-part gate, and the order matters:
PLEX_AXI_ALLOW_WRITES=truein the environment. This is the gate. It is a variable rather than a flag because of who sets it: the operator, once, outside the invocation. Something composing a command line cannot grant itself a permission it was not given, and the refusal names what to set. With it unset, a mutating command never contacts the server at all — the check runs before the connection is opened, so the attempt is not even something the server hears about.--writeon the invocation. Without it the command still runs: it reads the item or the playlist, prints the change it would make, and sends nothing.
$ plex-axi rate 12345 --stars 5
error: "refusing to rate 12345: writes are disabled (PLEX_AXI_ALLOW_WRITES is not set)"
code: WRITES_DISABLED
help[3]:
Run `export PLEX_AXI_ALLOW_WRITES=true`, then run the command again with --write
...
plex-axi api stays GET only whatever the gate says. A raw path that could POST would make the
gate meaningless — anything a typed command refused could be reissued by hand — and several Plex
write endpoints are destructive.
There is still no playback, no speaker, no metadata editing and no server administration. Rating a track and editing a playlist are not dispatch; playing one is.
What media_id is, and what consumes it
Every command that identifies one item prints a block like this and then stops:
item:
media_id: "plex://a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0/111"
rating_key: 111
guid: "plex://track/a1b2c3d4e5f60718293c0111"
note: "rating_key is local to this server and changes when an item is re-matched or the library is rebuilt; guid is the identifier that survives, so keep them together"
media_id is plex://<machineIdentifier>/<ratingKey>: the id of the item, prefixed by the id of
the server it lives on. That combination is what a media player needs to fetch it without being told
separately which Plex server you meant, and it is the form most Plex clients and integrations accept
as a media content id.
It matters that it is that form. Five plex:// strings circulate and they all look alike. Two of
them break a consumer: plex://track/<ratingKey> parses the word track as a server name and
resolves to a server that does not exist, and plex://track/<24-hex> — which is a perfectly
legitimate Plex identifier, the one printed here as guid — raises an error inside consumers that
expect a number in that position. plex-axi emits only the safe form, and a test sweeps every
command asserting it never emits the other two.
plex-axi does not dispatch it anywhere. Handing it to something that plays is a single call in
whatever already owns your speakers. In Home Assistant, for example, the media_player.play_media
service takes it directly:
service: media_player.play_media
target:
entity_id: media_player.example_speaker
data:
media_content_type: music
media_content_id: "plex://a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0/111"
That is illustrative, not a dependency: nothing in plex-axi knows Home Assistant exists, and the
same id is what Plex's own clients and other integrations consume. Substitute whatever plays music
where you are.
Keep the guid with the rating_key wherever you write one down. A rating key is a row number
in one server's database. It changes when an item is re-matched or the library is rebuilt, and the
same number will then resolve to a different recording — silently. The guid is the identifier that
survives, which is why both are printed together and why the note travels with them.
Agent integration
Install the skill so an agent loads the guidance on demand:
npx skills add dmealing/plex-axi --skill plex-axi
skills/plex-axi/SKILL.md is generated from the CLI's own command table, so it cannot describe a
flag that does not exist.
Design notes
- The client library is
python-plexapi, chosen for its model rather than its transport: it is the only Plex library withMusicSection, the filter language, and server-side field validation. It is synchronous, which is a hazard in a long-running service and simply correct in a one-shot CLI — do not "fix" it. - Plex's media-query language is documented in the community OpenAPI specification (https://github.com/LukeHagar/plex-api-spec, MIT), which is the thing to cite rather than re-derive.
- Development notes, including every sharp edge behind the code, are in AGENTS.md.
Contributing
pip install -e ".[dev]"
scripts/install-hooks.sh
pytest && ruff check . && ruff format --check . && scripts/leakcheck.py
This repository is public and a music library is full of identifying content, so a leak guard runs in a pre-commit hook, a commit-msg hook and CI. Its coverage is bounded and it does not replace review: it detects shapes — tokens, addresses, machine identifiers, media paths — and it cannot detect a real artist name, which has no shape. Use obviously-synthetic content everywhere, including tests and fixtures.
Tests never need a live Plex server or a real token, and must not start to.
Licence
MIT. See LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file plex_axi-0.2.0.tar.gz.
File metadata
- Download URL: plex_axi-0.2.0.tar.gz
- Upload date:
- Size: 155.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c152b7e234ce09c83f9b159ad2ad9322060f9abdb32535175a20eba80de73236
|
|
| MD5 |
b762edc8a729c79861c4dbfbd7f8e82b
|
|
| BLAKE2b-256 |
5f5a3999378c229a14abd859558547a97737bc6348d8aa555bdd1433c06bcc3b
|
Provenance
The following attestation bundles were made for plex_axi-0.2.0.tar.gz:
Publisher:
release.yml on dmealing/plex-axi
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
plex_axi-0.2.0.tar.gz -
Subject digest:
c152b7e234ce09c83f9b159ad2ad9322060f9abdb32535175a20eba80de73236 - Sigstore transparency entry: 2531907056
- Sigstore integration time:
-
Permalink:
dmealing/plex-axi@eb4db39e955f92f9b9a2485aacd18ff92ab2910c -
Branch / Tag:
refs/heads/main - Owner: https://github.com/dmealing
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@eb4db39e955f92f9b9a2485aacd18ff92ab2910c -
Trigger Event:
push
-
Statement type:
File details
Details for the file plex_axi-0.2.0-py3-none-any.whl.
File metadata
- Download URL: plex_axi-0.2.0-py3-none-any.whl
- Upload date:
- Size: 93.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
392a96830aa9586ddf24c24b38e87e2b68c424abe9275b795bcd5e126a89a0f9
|
|
| MD5 |
82a6b37eff8df20af6caf315f3a519a2
|
|
| BLAKE2b-256 |
077a839bf1d6d2a0af51a9960c0519d777e3d122fb23342fdf7e4d930890a2c6
|
Provenance
The following attestation bundles were made for plex_axi-0.2.0-py3-none-any.whl:
Publisher:
release.yml on dmealing/plex-axi
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
plex_axi-0.2.0-py3-none-any.whl -
Subject digest:
392a96830aa9586ddf24c24b38e87e2b68c424abe9275b795bcd5e126a89a0f9 - Sigstore transparency entry: 2531907542
- Sigstore integration time:
-
Permalink:
dmealing/plex-axi@eb4db39e955f92f9b9a2485aacd18ff92ab2910c -
Branch / Tag:
refs/heads/main - Owner: https://github.com/dmealing
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@eb4db39e955f92f9b9a2485aacd18ff92ab2910c -
Trigger Event:
push
-
Statement type: