plex-axi
Search a Plex music library field by field, from a command line built for agents. Every value
gets its own flag — artist, track, genre, mood, year, minimum rating — and Plex evaluates the whole
predicate server-side. Every answer ends at a labelled media_id, the identifier you hand to
whatever plays music where you are.
It does not play anything unless you switch playback on, and until you do it has no play command
at all. Out of the box the tool stops at the identifier, and dispatch belongs to whatever already
owns your speakers — which is the right answer if you run Home Assistant or anything like it, since
two things that can both start music means two things that disagree about what is playing. If you
have Plex and nothing else, that was a dead end, so PLEX_AXI_ALLOW_PLAYBACK=true adds two
commands: clients, which lists what can play, and play, which starts one track, album, artist
or playlist on one of them. Without that variable they are not refused — they do not exist, in the
help, in the skill or in the command table, so an agent cannot pick a path you did not open. There
is no pause, stop, volume or queue command in either case, and there is not going to be one.
It is an AXI tool — an Agent eXperience Interface, a convention for command
lines whose primary user is an LLM agent rather than a person. In practice that means
token-efficient structured output (TOON rather than JSON), no interactive
prompts, a non-zero exit on every failure, and errors that carry the command which fixes them. It
reads perfectly well in a terminal — --human prints aligned tables — it is simply not optimised
for one.
Why per-field 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 or track.originalTitle,has words beginning,Example Artist
track.title,has words beginning,Example Track
tracks[1]{key,media_id,title,artist}:
111,"plex://<machineIdentifier>/111",Example Track,Example Artist
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.
What it hands back is an identifier, and that is the end of it. What a media_id is and what
accepts one is below; why the tool refuses the step after
it, and the test that holds the line, are in 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
Two runtime dependencies come with it. PlexAPI carries
the Plex model layer; axi-toolkit carries the plex://
identifier forms and the music filter language, which the AXI CLIs share rather than each keeping a
copy of. axi-toolkit declares no runtime dependency of its own, so it adds exactly one name to the
environment and nothing transitive behind it.
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
export PLEX_AXI_ALLOW_PLAYBACK=true # only if you want `clients` and `play` to exist
export PLEX_ACCOUNT_TOKEN=<a plex.tv token> # only to reach Sonos speakers; see Playback
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, writes or starts playback.
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 format
Structured TOON on stdout by default — Token-Oriented Object Notation, a compact encoding of the same data JSON would carry, roughly 40% cheaper in tokens. A header names the columns once and each row is one line:
count: 2 of 47 total
tracks[2]{key,media_id,title,artist}:
111,"plex://a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0/111",Example Track,Example Artist
112,"plex://a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0/112",Second Example,Example Artist
Every list prints four columns at most by default — two identifiers, the title and one more —
and takes --fields for the rest: --fields key,title,album,year on a search, --fields key,title,items on playlist list, --fields key,title,artist,state on sessions. A track list
adds track_artist only on the compilation rows where the album artist alone would say "Various
Artists". Long values are previewed with their full length and a --full escape hatch, and api
caps each repeated element at twenty children for the same reason.
--humanrenders aligned tables for a person.--jsonemits raw JSON, for anything that would rather parse than read.- Errors go to stdout too, in the same structured shape, and carry the command that fixes them,
so a wrong flag corrects itself in one turn. stderr carries only diagnostics (
--debug), which agents do not read. - Exit codes:
0success — including a search that matched nothing, because an empty answer is an answer, a write whose result already holds (adding a track a playlist already has, removing one it does not, repeating a create), which is reported as a no-op, and the bareplex-axihome view, which reports an unconfigured or unreachable server as its content with the next step;1the outcome of a lookup against live state (nothing at that rating key, no music library on the server, an ambiguous section);2a usage error (unknown command, unknown flag, a rating key that is not a number, a write method onapi). - Unknown flags and extra arguments are rejected by name rather than ignored, with that subcommand's valid flags listed inline so the correction takes one turn, not two.
One documented deviation: help[N]: blocks render one suggestion per line rather than as a
delimiter-joined TOON array. Suggestions are command lines that routinely contain commas, and this
is the shape the AXI standard and the sibling AXI CLIs use. Every data structure is strict TOON,
and "strict" is a test result rather than a claim: the specification's own conformance fixtures are
vendored into the suite and every one of them has to pass.
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 name matches by word, not by substring. Each word typed has to begin a word of the title,
in the order typed:
exa trafinds "Example Track" andxamplefinds nothing. That is the server's rule, and the echoed filter sayshas words beginningrather than the server's own label, "contains". Punctuation is searched both ways — an apostrophe, quote, hyphen or ellipsis typed in ASCII also matches its typographic form, which is what most catalogue titles carry — and a comma in a value is dropped, because the server reads it as OR. --artiston a track search also matches the track's performer, so a performer who appears only on compilations — where the album artist is "Various Artists" — is found by name.- A zero result is an answer. It names the filters that matched nothing and the command that
lists the real vocabulary. When a name matched nothing, the nearest real titles are listed under
nearest, from the server's own free-text search — the one place it folds accents — so the next search is on a string the library actually holds. - Anything a row prints as an identifier is accepted back. Every command that takes a rating
key takes the
media_idfrom the same row too, once it has checked the id names this server. - Every row carries a
media_id. That is what the tool is for: a labelled identifier a media consumer accepts, on every row of every list, so nothing needs a follow-up call to be usable. --fieldsreplaces the default columns, it does not add to them. The default set is a suggestion and may grow a column when the data warrants one (track_artiston a compilation), so name the columns if you need a fixed schema.--rated-minis a minimum in stars, and0is not a filter. Zero is the bottom of the scale and narrows nothing;--rated-min 0.5is what asks for everything that carries a rating at all.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 — unless the guid readslocal://<rating_key>, which is what Plex gives an item it never matched to its catalogue. That one is the rating key with a scheme in front of it, so it moves with it; the detail view says so rather than promising otherwise, and there is nothing durable to write down for those items.- 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-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]:
Writes are the operator's decision: ask them to set PLEX_AXI_ALLOW_WRITES=true in the environment this tool is launched from, and do not set it yourself
...
The refusal names the variable for the operator and deliberately prints no command that sets it:
a line beginning Run is one an agent executes, and an agent with a shell could open the gate it
had just been refused by.
plex-axi api stays GET only whatever the gate says, and a GET is not enough on its own. A raw
path that could POST would make the gate meaningless — anything a typed command refused could be
reissued by hand — and Plex also acts on a plain GET at several paths: /:/rate sets a rating,
/library/sections/<n>/refresh starts a scan, a /player/ path relays a command to a client. So
api refuses those paths by name as well as refusing the write methods, before any request is
sent, with either gate open or closed. It also prints <redacted> for any attribute the server
names as a credential: /myplex/account carries the owner's plex.tv account token.
There is still no metadata editing and no server administration, and no transport control: play
starts one thing on one target and nothing in this tool can pause, skip or stop it.
Playback
Off by default, and while it is off the commands are not there to be found — not in --help, not
in the generated skill, not in the no-argument view, not in the ambient context a session hook
prints. That is deliberate, and the default is the important half.
If you run Home Assistant, or anything else that already dispatches music, leave this switched off. Your automation owns the speakers — Sonos included, which it reaches by its own path — and the shape that works is to use this tool to find music and let that system play it. A play issued here goes around it, so its state is stale the moment the command succeeds; the problem is not that nothing happens, it is that everything downstream is confidently wrong about what is playing. While the switch is off an agent cannot see this path at all, which is the only reliable way to stop it choosing one.
Switch it on if you have Plex and nothing else doing that job — which is exactly who it is for, because without it the tool hands you an identifier and stops.
export PLEX_AXI_ALLOW_PLAYBACK=true
plex-axi clients # what can actually play
plex-axi play 12345 --client 'Living Room' # says what it would do, sends nothing
plex-axi play 12345 --client 'Living Room' --now # starts it
The gate is an environment variable and --now is the confirmation, exactly as with writes: the
variable is your standing decision, the flag is this invocation's. Leaving --now off is not a nag,
it is a cheaper question — it resolves the item and the target and tells you which one it picked and
why, which is where "I have three clients and named none of them" gets caught before a speaker comes
on. If there is exactly one target it is used, and the answer says it was the only candidate.
clients lists only targets that advertise Plex's playback capability; anything else that
answered is counted, because a client that cannot play will accept the command and do nothing. No
network address is printed for any target — --client takes the exact title, or the machine_id
printed beside it when two targets share a name.
Two routes, and the second has conditions:
| Route | Reached via | Needs |
|---|---|---|
local |
the server's own /clients list |
nothing beyond PLEX_TOKEN; the client's app must be running on the same network as the server |
sonos |
sonos.plex.tv |
PLEX_ACCOUNT_TOKEN, a Plex Pass, speakers linked to that account, and remote access working |
PLEX_ACCOUNT_TOKEN is a plex.tv account token, which is not the same thing as PLEX_TOKEN — a
server token is refused by plex.tv outright, and the error says so rather than handing you a bare
401. The Sonos route is consulted only when that variable is set, and clients says which routes it
asked either way. It goes over Plex's cloud rather than through your server, so anything watching
your server for a session will lag the command; that matters if you run Home Assistant, and it is
another reason to leave this gate shut where something else already dispatches.
Starting playback is all play does. There is no pause, stop, resume, seek, next, previous, volume
or queue command, and there is not going to be one: a start button is a handoff, and a transport is
a second system believing it owns your queue.
Both routes ship without having started music on real hardware. Everything up to the final
request is confirmed against a live Plex Media Server — the play queue is created for a track, an
album and a playlist, and the empty-client case is handled — but no Plex client advertised to the
server while this was written, and no plex.tv account token was available for Sonos. If you are the
first to use it, --now is the moment to find out, and a report either way is welcome on the issue
tracker.
What media_id is, and what consumes it
Every command that identifies one item prints a block like this and then stops — play, which
Playback can switch on, prints the same block and then starts the item:
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. Six 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.
Every row of every list carries a media_id too, not just the single-item block above. A list
view that printed a bare key would end one call short of the thing the tool exists to produce.
The guid is not in the default row: it is the identifier you write down rather than the one a
consumer takes, and it is available by name (--fields key,title,guid) and in the detail views.
A playlist has one as well. plex-axi playlist list and plex-axi playlist show print a
media_id for the playlist itself, so playing a whole playlist needs no more assembly than playing
one track does. A playlist's rating key lives in the same /library/metadata namespace, so the same
plex://<machineIdentifier>/<ratingKey> form resolves to the playlist — verified on a real server
and against a real consumer, not assumed.
Out of the box 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 — or, if nothing does, Playback switches on a play command that takes
this rating key directly.
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 usually the
identifier that survives, which is why both are printed together and why the note travels with them.
"Usually", because of one shape. An item Plex never matched to its catalogue carries
local://<rating_key> — the rating key with a scheme in front of it — and that is common enough to
matter, roughly one track in seven on a real library. Such a guid moves exactly when the rating key
moves, so there is nothing durable to record for those items and the note says that instead of
promising the opposite. Match them by artist and title if you need them to survive a rebuild.
Agent integration
Two ways to make this discoverable. You only need one.
Session hook — ambient context in every session, for agents that support hooks:
plex-axi setup hooks
Installs a SessionStart hook for Claude Code (~/.claude/settings.json) and Codex
(~/.codex/hooks.json, plus [features] hooks = true), and a managed ambient-context plugin for
OpenCode. It is idempotent, repairs the recorded path after a reinstall or a move, leaves other
tools' hooks alone, and refuses to overwrite a plugin it does not manage. It writes to your own
home directory and to nothing on the Plex server.
plex-axi setup status # installed, stale, missing or unmanaged, per target; writes nothing
plex-axi setup remove # takes out exactly what `setup hooks` put in; safe to repeat
Claude Code also gets a SessionEnd hook, plex-axi context end, which counts the plex-axi
commands the session ran — command names only, never their arguments, so no artist, title or
token is ever written down — into ~/.local/state/plex-axi/sessions.json. The next session's
context in the same directory then carries one last_session: line saying what was done there.
Codex and OpenCode have no session-end event, so they get the context alone. setup remove leaves
Codex's [features] hooks = true in place, because other tools' Codex hooks depend on it.
What the hook puts in front of a session is plex-axi context — and unlike most AXI tools, that is
deliberately not the no-argument view. A hook runs at the start of every session, on every
machine that has the package, before anybody has decided to use the tool, so it must not need a
token, must not open a connection, and must not put your server's address into an agent's context.
context reads the environment, the command table and that local session record and nothing else,
and exits 0 whether or not this machine has a Plex server at all:
$ plex-axi context
bin: ~/.local/bin/plex-axi
description: "Structured, per-field music search and diagnosis against a Plex Media Server. Prefer this over raw curl or a free-text search for anything about a music library."
config: PLEX_URL and PLEX_TOKEN are set
writes: disabled (the operator enables them with PLEX_AXI_ALLOW_WRITES=true)
search: "one flag per field -- --artist, --album, --track, --genre, --mood, --style, --year, --rated-min -- because Plex matches them separately; --query searches one unstructured string and is the fallback, not the default"
media_id: "every row carries one, spelled plex://<machine-id>/<rating-key>, and that is where this tool ends: it leaves dispatch to whatever owns the speakers"
vocabulary: "--genre and --style match the artist, --mood the type being searched; run `plex-axi genres` (or `moods`, `styles`) for the exact strings this server will accept"
commands[18]: search,pick,genres,moods,styles,track,album,artist,similar,recent,playlist,rate,sessions,api,doctor,setup,skill,context
help[3]:
Run `plex-axi` for this library at a glance: the server, its size, what arrived recently and what is playing
Run `plex-axi search --artist '<name>' --track '<title>'` to search field by field
Run `plex-axi <command> --help` for its flags, or `plex-axi --help` for all of them
config reports whether the two variables are set and never what they hold. The live library —
the server, its size, what arrived recently, what is playing — is what plex-axi with no arguments
prints, when somebody has asked for it.
Agent Skill — loads on demand, no per-session token cost, works in any agent that supports skills:
npx skills add dmealing/plex-axi --skill plex-axi
skills/plex-axi/SKILL.md is generated from the CLI's own command table by plex-axi skill, and
scripts/ci-local.sh runs plex-axi skill --check, so it cannot describe a flag that does not exist or drift from
the commands it documents.
Using it as a library
The rules above about how a Plex Media Server reads a name and answers a request are not the
command line's alone. plex_axi.toolkit is the supported import surface of this package for a
program that talks to the same server through its own code — plain values in, plain values out,
no connection opened and nothing printed. It is new and may grow; the names in its __all__ are
the contract.
from plex_axi import toolkit
# What the server should be sent: commas and doubled spaces taken out, and each
# punctuation spelling a title might hold. None means there is nothing to search for.
toolkit.filter_value("Don't Stop, Example")
# ["Don't Stop Example", 'Don’t Stop Example']
# The server matches word prefixes, so a search for one artist returns others too.
# Choose among what came back, and never by position:
held = ["Example Band", "Example", "The Example Trio"]
result = toolkit.resolve_name("example", held)
result.status, result.match # ('resolved', 'Example')
result = toolkit.resolve_name("exam", held)
result.status, result.candidates # ('missing', ('Example Band', 'Example', 'The Example Trio'))
A refusal is data rather than an exception: a name that folds to more than one held name comes
back ambiguous with the tied candidates, a miss comes back missing with the near ones, and
neither picks the first. The same package holds the exact-name filter for a name that is a word
inside somebody else's (exact_matches), the compilation rules for who is actually performing
(PERFORMER_FIELD, performer_honoured, track_artist), the checks on whether an answer is
Plex's at all (answer_kind, acts_on_get), and the reading of the identifiers this tool
prints (parse_reference). It is synchronous on purpose and imports only the standard library,
so an asynchronous program can run it in a thread; a function that needs the server's answer
takes it already fetched, or takes a callable you supply.
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. -
The
plex://identifier forms and the music filter language — stars, field scoping, the one inequality real Plex offers for an integer, relative dates and sorts — come fromaxi-toolkit, the shared package the AXI CLIs build on. What that costs an installation is stated under Install, where somebody deciding whether to install it will actually read 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.
-
Playback reaches a local client through the server's own remote-control path and a Sonos speaker through one documented
sonos.plex.tvendpoint parsed with the standard library. It deliberately does not use the client library'sPlexClient/MyPlexAccount/plexapi.sonosobject model, which is asserted by a test: that surface resolves speakers by name and dispatches to them, and keeping it out of the process is what stops "play one thing on one named target" growing into a control plane by accident. -
How a typed name is matched, and why it is more than one predicate. Measured against a real server, Plex's title filter does three things its own label ("contains") does not say: it splits a value on commas into alternatives, and an empty alternative matches everything; it matches word prefixes in order, not substrings; and it folds nothing — an ASCII apostrophe does not match the typographic one most catalogue titles carry, and an unaccented letter does not match an accented one. Roughly one track in sixteen on an ordinary library has typographic punctuation, so a name typed the way anybody types it missed, and the recovery hint looped between two zero results. The design, in four parts:
- Clean the value. Commas become spaces and whitespace is collapsed; a value with nothing left is refused before the request, because it would match the whole library.
- Send each punctuation spelling. A value holding
',",-or...is sent as typed and in its typographic spellings, as alternatives in one predicate — the comma-OR, used on purpose. At most four, and a value with no punctuation costs nothing. - On a track search,
--artistis the album artist OR the performer. A compilation track's album artist is "Various Artists", so the performer field is ORed in, server-side, in one parenthesised group. The server accepts that field and does not advertise it, so the rows are checked: if not one of them carries the name, the field did not run, the search is repeated on the album artist alone, and the result says so. - On zero results, ask the server what was meant. Accents cannot be expanded into spellings,
so the server's own free-text search — the one place it folds them — is asked for the nearest
titles, which are printed under
nearestas exact strings to search on. It is offered, never substituted: the free-text search is the weak path this tool exists to replace.
What this deliberately does not do is filter or re-rank rows in Python. Every predicate is still evaluated by Plex over the whole library, so totals stay exact and
--limitstill means what it says. The rules are pure functions inplex_axi.toolkit, with no server in them. Because this changes what a search returns, it is re-checked against a real server with the live suite's sweeps (scripts/live-test.sh -k search) before a release. -
Development notes, including every sharp edge behind the code, are in AGENTS.md.
Contributing
scripts/dev-setup.sh # builds .venv and installs this checkout into it
scripts/install-hooks.sh
.venv/bin/pytest && .venv/bin/ruff check . && .venv/bin/ruff format --check . && scripts/leakcheck.py
Set up through that script rather than with a bare editable install — pip install with -e,
against whatever interpreter is ambient. plex-axi is normally installed as an isolated user-level
tool, and such an install replaces that installation — then breaks it, with a ModuleNotFoundError
and no
explanation, the moment this checkout goes away. The script builds a virtualenv at .venv and
installs into that, which cannot reach outside the checkout; call the tools out of .venv/bin for
the same reason, so nothing resolves to a copy installed elsewhere. scripts/leakcheck.py needs no
virtualenv — it is standard library only, so the git hooks can run it.
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, scripts/ci-local.sh, and — on every open, push and edit — over the pull
request's own title and body. That last one is not a file: a title and a body are published the
moment they are written, are in no checkout and pass under no hook, and tooling routinely writes
into a body, where an embedded script's worktree variable or a pytest header's rootdir: line is
an absolute home path. It fails the check when it cannot read the pull request rather than reporting
a clean it cannot support, and it reports the field, line and rule of a match — plus the offset when
the finding's pass read the text as written — without printing the match, because a CI log is more
public than the page it came from. For the same reason a pull request cannot carry an allow=
marker: in a file that marker is committed and reviewed, and in a body it is an off-switch anyone
can add after every check has run.
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.
scripts/leakcheck.py # scan every tracked file
scripts/leakcheck.py --staged # scan what a commit would actually record
scripts/leakcheck.py --commit-msg <path> # scan a commit message
scripts/leakcheck.py --pull-request <n> # scan a pull request's title and body
scripts/leakcheck.py --rules # list the rules, the surfaces, and the allowances
scripts/leakcheck.py --demo # self-test: prove every rule still fires
The TOON encoder is held to the specification's own opinion as well as to this project's:
every official encode fixture is vendored byte-for-byte from
toon-format/spec and runs on every pytest. The case count
is asserted too, so a fixture that stops being collected fails the suite instead of quietly lowering
the score, and so are the per-file checksums, so a fixture edited to suit the encoder fails as well.
Commit messages are checked as well as scanned. release-please builds the changelog and the version
bump from them, and when its parser cannot read one it says so at debug level, drops the commit and
exits 0 — a fix merged to main that is never published, with a green release run over it. So
scripts/commitcheck.py refuses a message that parser would reject, from the same commit-msg hook,
and the release workflow re-checks every commit since the last tag. Rich commit bodies are the point
of this history and nothing here restricts them; the one shape to know is that a body line must not
begin with a word run straight into an unclosed or nested parenthesis — `Decimal(repr(v))`
at a line start is refused, and the same phrase one word further along the line is fine. Run
scripts/commitcheck.py --rules for the grammar rule and its citation.
The default suite never needs a live Plex server or a real token, and must not start to. It runs
the real client library against a double, and four checks keep that honest: a shape contract
(tests/test_shape_contract.py) holds every table and element in the double to a names-only capture
of a real server, refreshed with scripts/shape-capture.py --write; golden files under
tests/golden/ pin every help, error and refusal text byte for byte (accept a deliberate change
with PLEX_AXI_UPDATE_GOLDEN=1 .venv/bin/pytest tests/test_golden.py); every suggested command is
parsed with the CLI's own parser and run through a real shell; and default output is decoded with
the official TOON decoder and compared with --json.
The live suite is opt-in and separate. tests/live/ talks to a real server and is deselected
by default — pytest, scripts/ci-local.sh and the gate never run it:
export PLEX_URL=... PLEX_TOKEN=... # for the length of one shell session
scripts/live-test.sh # reads and previews only
scripts/live-test.sh --writes # also the two self-reversing writes
Every invocation goes through a recording proxy that forwards GETs only, so "reaches the server zero
times" is observed on the wire; a non-GET is answered 403 unless a write test armed it, and a
playback request is never forwarded at all. Nothing is played, nothing is scanned, and plex.tv is
never reached. --writes sets and clears one unrated track's rating and creates and deletes one
playlist named zz-plex-axi-live-test-<epoch>, each restored and verified against the raw API.
Samples are chosen from the live library by shape at run time — a title with a typographic
apostrophe, a title with a comma — so no real name is written into this repository. Captured output
goes to the git-ignored .live-output/; it holds real library content, so never commit or paste
it. A change to what the server is asked, or to what is read from its answer, is not finished
until this has been run.
Changelog
Every release is recorded in CHANGELOG.md, generated from Conventional Commit messages by release-please.
Licence
MIT. See LICENSE.
tests/fixtures/toon-spec/ vendors the TOON specification's conformance fixtures, which are MIT
licensed and copyright their authors; the upstream licence, the commit they came from and the
refresh recipe are recorded beside them in PROVENANCE.md.
Metadata
Release files for plex-axi 0.8.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| plex_axi-0.8.0.tar.gz | 487.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| plex_axi-0.8.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 667.6 kB
Release files / plex_axi-0.8.0.tar.gz
| Download URL | plex_axi-0.8.0.tar.gz |
|---|---|
| Size | 487.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
46d63d16833ea73e678890f43028bfb7d461cd574009e7f6b9522b533a65f8ec
|
|
BLAKE2b-256 checksum How to use checksums |
55935a0e30aba008cae66c6994f603709faf1aa241bc6d415faa11356b9180ac
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.2
|
Release files / plex_axi-0.8.0-py3-none-any.whl
| Download URL | plex_axi-0.8.0-py3-none-any.whl |
|---|---|
| Size | 180.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
00e5dfd1b9f230849032322f9d546f4d410d1ca064e8b9b29e6aa93a1ee2ed21
|
|
BLAKE2b-256 checksum How to use checksums |
73b083f7fec5d06b3c472e8bfe2ec0b962ebe02ca8c51541650092ef6751b72c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.2
|