plex-axi 0.1.0__tar.gz

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.
Files changed (57) hide show
  1. plex_axi-0.1.0/.githooks/commit-msg +16 -0
  2. plex_axi-0.1.0/.githooks/pre-commit +17 -0
  3. plex_axi-0.1.0/.github/workflows/ci.yml +149 -0
  4. plex_axi-0.1.0/.github/workflows/hygiene.yml +43 -0
  5. plex_axi-0.1.0/.github/workflows/release.yml +59 -0
  6. plex_axi-0.1.0/.gitignore +16 -0
  7. plex_axi-0.1.0/.release-please-manifest.json +3 -0
  8. plex_axi-0.1.0/AGENTS.md +350 -0
  9. plex_axi-0.1.0/CHANGELOG.md +13 -0
  10. plex_axi-0.1.0/CLAUDE.md +1 -0
  11. plex_axi-0.1.0/LICENSE +21 -0
  12. plex_axi-0.1.0/PKG-INFO +216 -0
  13. plex_axi-0.1.0/README.md +191 -0
  14. plex_axi-0.1.0/pyproject.toml +67 -0
  15. plex_axi-0.1.0/release-please-config.json +12 -0
  16. plex_axi-0.1.0/scripts/install-hooks.sh +11 -0
  17. plex_axi-0.1.0/scripts/leakcheck.py +651 -0
  18. plex_axi-0.1.0/skills/plex-axi/SKILL.md +241 -0
  19. plex_axi-0.1.0/src/plex_axi/__init__.py +6 -0
  20. plex_axi-0.1.0/src/plex_axi/__main__.py +10 -0
  21. plex_axi-0.1.0/src/plex_axi/argspec.py +312 -0
  22. plex_axi-0.1.0/src/plex_axi/cli.py +483 -0
  23. plex_axi-0.1.0/src/plex_axi/commands/__init__.py +13 -0
  24. plex_axi-0.1.0/src/plex_axi/commands/_common.py +116 -0
  25. plex_axi-0.1.0/src/plex_axi/commands/api.py +205 -0
  26. plex_axi-0.1.0/src/plex_axi/commands/doctor.py +146 -0
  27. plex_axi-0.1.0/src/plex_axi/commands/genres.py +124 -0
  28. plex_axi-0.1.0/src/plex_axi/commands/home.py +158 -0
  29. plex_axi-0.1.0/src/plex_axi/commands/item.py +328 -0
  30. plex_axi-0.1.0/src/plex_axi/commands/recent.py +96 -0
  31. plex_axi-0.1.0/src/plex_axi/commands/search.py +210 -0
  32. plex_axi-0.1.0/src/plex_axi/commands/sessions.py +80 -0
  33. plex_axi-0.1.0/src/plex_axi/commands/similar.py +169 -0
  34. plex_axi-0.1.0/src/plex_axi/commands/skill.py +78 -0
  35. plex_axi-0.1.0/src/plex_axi/config.py +183 -0
  36. plex_axi-0.1.0/src/plex_axi/errors.py +55 -0
  37. plex_axi-0.1.0/src/plex_axi/ids.py +129 -0
  38. plex_axi-0.1.0/src/plex_axi/music.py +574 -0
  39. plex_axi-0.1.0/src/plex_axi/output.py +254 -0
  40. plex_axi-0.1.0/src/plex_axi/plex.py +284 -0
  41. plex_axi-0.1.0/src/plex_axi/skill.py +137 -0
  42. plex_axi-0.1.0/src/plex_axi/toon.py +311 -0
  43. plex_axi-0.1.0/tests/conftest.py +1068 -0
  44. plex_axi-0.1.0/tests/test_cli.py +206 -0
  45. plex_axi-0.1.0/tests/test_commands.py +287 -0
  46. plex_axi-0.1.0/tests/test_config.py +76 -0
  47. plex_axi-0.1.0/tests/test_credentials.py +127 -0
  48. plex_axi-0.1.0/tests/test_doctor.py +142 -0
  49. plex_axi-0.1.0/tests/test_ids.py +127 -0
  50. plex_axi-0.1.0/tests/test_leakcheck.py +344 -0
  51. plex_axi-0.1.0/tests/test_no_dispatch.py +191 -0
  52. plex_axi-0.1.0/tests/test_output.py +85 -0
  53. plex_axi-0.1.0/tests/test_python_floor.py +110 -0
  54. plex_axi-0.1.0/tests/test_search.py +273 -0
  55. plex_axi-0.1.0/tests/test_skill.py +65 -0
  56. plex_axi-0.1.0/tests/test_toon.py +139 -0
  57. plex_axi-0.1.0/tests/test_transport.py +73 -0
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env sh
2
+ # Block a commit message that would publish library-specific data.
3
+ #
4
+ # File content and commit messages are separate channels; --staged only covers
5
+ # the former, and a message is just as public.
6
+ set -e
7
+
8
+ root=$(git rev-parse --show-toplevel)
9
+ python=$(command -v python3 || command -v python || true)
10
+
11
+ if [ -z "$python" ]; then
12
+ echo "commit-msg: no python interpreter found; cannot run the leak check" >&2
13
+ exit 1
14
+ fi
15
+
16
+ "$python" "$root/scripts/leakcheck.py" --commit-msg "$1"
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env sh
2
+ # Block a commit that would publish library-specific data.
3
+ #
4
+ # Install with: scripts/install-hooks.sh
5
+ # The same scan runs in CI, so bypassing this hook only delays the failure.
6
+ set -e
7
+
8
+ root=$(git rev-parse --show-toplevel)
9
+ python=$(command -v python3 || command -v python || true)
10
+
11
+ if [ -z "$python" ]; then
12
+ echo "pre-commit: no python interpreter found; cannot run the leak check" >&2
13
+ echo "pre-commit: install python3, or run the scan manually before pushing" >&2
14
+ exit 1
15
+ fi
16
+
17
+ "$python" "$root/scripts/leakcheck.py" --staged --root "$root"
@@ -0,0 +1,149 @@
1
+ # Local CI — the heavy validation matrix, on the maintainer's self-hosted runner.
2
+ #
3
+ # SECURITY (public repository + self-hosted runner): this workflow must NEVER
4
+ # gain a `pull_request` trigger. The runner is the maintainer's own workstation,
5
+ # and a job on it executes repository code as a real user with that user's files
6
+ # and network. Every trigger below is reachable only by someone who already has
7
+ # write access — a push to `main`, the schedule, or a manual dispatch — so
8
+ # fork-submitted code cannot reach the machine. `pull_request` would hand any
9
+ # contributor on the internet code execution on it, in one line and with no
10
+ # other visible symptom. Add coverage for pull requests in `hygiene.yml`, which
11
+ # is GitHub-hosted, or not at all.
12
+ #
13
+ # WHERE VALIDATION ACTUALLY HAPPENS: every change here goes through the local
14
+ # no-mistakes gate — review, tests, lint, docs — before a PR is ever opened.
15
+ # GitHub-hosted CI is not the primary quality signal for this repository and
16
+ # should not be treated as one, which is why a pull request shows a single cheap
17
+ # check (the leak scan, the one gate that must run before a human reads a diff)
18
+ # rather than a matrix. This workflow is the full matrix; it runs on `main`,
19
+ # nightly, and on demand.
20
+ #
21
+ # The reasoning above is carried across from the sibling AXI project, where the
22
+ # arrangement this replaced triggered on both `push: branches: ["**"]` and
23
+ # `pull_request`, so every PR branch ran the whole matrix twice against identical
24
+ # commits: one copy went green while its twin sat queued for over an hour,
25
+ # leaving the PR permanently "unstable".
26
+ name: ci (self-hosted)
27
+
28
+ on:
29
+ push:
30
+ branches: [main]
31
+ # Nightly. Deliberately not 08:17 or 09:41 UTC: sibling projects' self-hosted
32
+ # workflows hold those slots on the same workstation.
33
+ schedule:
34
+ - cron: '23 10 * * *'
35
+ workflow_dispatch:
36
+
37
+ permissions:
38
+ contents: read
39
+
40
+ concurrency:
41
+ group: ci-${{ github.ref }}
42
+ cancel-in-progress: true
43
+
44
+ jobs:
45
+ leakcheck:
46
+ name: leak check
47
+ runs-on: [self-hosted, linux]
48
+ timeout-minutes: 10
49
+ steps:
50
+ - uses: actions/checkout@v4
51
+ with:
52
+ # The workspace on a self-hosted runner outlives the job; do not leave
53
+ # a credential behind in its .git/config.
54
+ persist-credentials: false
55
+ - uses: actions/setup-python@v5
56
+ with:
57
+ python-version: "3.12"
58
+ # Prove the scanner still detects what it claims to before trusting it.
59
+ - name: Self-test against a synthetic dirty fixture
60
+ run: python3 scripts/leakcheck.py --demo
61
+ - name: Scan every tracked file
62
+ run: python3 scripts/leakcheck.py
63
+
64
+ # EVERY job that needs third-party packages installs into a fresh `.venv` and
65
+ # calls the tools by path out of it. That is not ceremony. This runner runs as
66
+ # the maintainer's own user, so `~/.local/lib/python3.X/site-packages` and
67
+ # `~/.local/bin` are on the interpreter's path and ahead of it on `PATH` — a
68
+ # bare `pytest`, `ruff` or `plex-axi` resolves to whatever the maintainer
69
+ # happens to have installed, against `/usr/bin/python3.X` rather than the
70
+ # interpreter this job set up. A venv excludes the user site by construction,
71
+ # so a job sees only what it just installed. `--clear` because the workspace
72
+ # is reused between jobs.
73
+ lint:
74
+ name: lint
75
+ runs-on: [self-hosted, linux]
76
+ timeout-minutes: 10
77
+ steps:
78
+ - uses: actions/checkout@v4
79
+ with:
80
+ persist-credentials: false
81
+ - uses: actions/setup-python@v5
82
+ with:
83
+ python-version: "3.12"
84
+ - run: python -m venv --clear .venv
85
+ - run: .venv/bin/python -m pip install --upgrade pip
86
+ - run: .venv/bin/pip install -e ".[dev]"
87
+ - run: .venv/bin/ruff check .
88
+ - run: .venv/bin/ruff format --check .
89
+
90
+ test:
91
+ name: test (py${{ matrix.python-version }})
92
+ runs-on: [self-hosted, linux]
93
+ timeout-minutes: 20
94
+ strategy:
95
+ fail-fast: false
96
+ matrix:
97
+ # The lowest entry here is the floor `requires-python` publishes, and
98
+ # `tests/test_python_floor.py` fails if the two ever disagree. 3.9 was
99
+ # dropped because plexapi 4.18.0 dropped it: the declared floor said 3.9
100
+ # while the dependency graph could not be resolved there at all.
101
+ python-version: ["3.10", "3.11", "3.12"]
102
+ steps:
103
+ - uses: actions/checkout@v4
104
+ with:
105
+ persist-credentials: false
106
+ - uses: actions/setup-python@v5
107
+ with:
108
+ python-version: ${{ matrix.python-version }}
109
+ - run: python -m venv --clear .venv
110
+ - run: .venv/bin/python -m pip install --upgrade pip
111
+ - run: .venv/bin/pip install -e ".[dev]"
112
+ - run: .venv/bin/pytest
113
+
114
+ skill:
115
+ name: skill is current
116
+ runs-on: [self-hosted, linux]
117
+ timeout-minutes: 10
118
+ steps:
119
+ - uses: actions/checkout@v4
120
+ with:
121
+ persist-credentials: false
122
+ - uses: actions/setup-python@v5
123
+ with:
124
+ python-version: "3.12"
125
+ - run: python -m venv --clear .venv
126
+ - run: .venv/bin/python -m pip install --upgrade pip
127
+ - run: .venv/bin/pip install -e ".[dev]"
128
+ # The committed skill is generated from the CLI's own command table — from
129
+ # THIS checkout's, which is why it is the venv's `plex-axi` and not PATH's.
130
+ - run: .venv/bin/plex-axi skill --check
131
+
132
+ # Nothing watches a workflow that runs on main and overnight, so a failure has
133
+ # to come and find someone.
134
+ notify:
135
+ needs: [leakcheck, lint, test, skill]
136
+ if: always() && contains(needs.*.result, 'failure')
137
+ runs-on: [self-hosted, linux]
138
+ timeout-minutes: 5
139
+ steps:
140
+ - env:
141
+ # env-indirection: a commit message is untrusted text and must never be
142
+ # interpolated with ${{ }} directly into a script body.
143
+ HEAD_MSG: ${{ github.event.head_commit.message }}
144
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
145
+ run: |
146
+ export DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$(id -u)/bus"
147
+ notify-send -u critical "ci FAILED: plex-axi" \
148
+ "$(printf '%s' "$HEAD_MSG" | head -n1)
149
+ $RUN_URL" || echo "notify-send unavailable; GitHub email still fires"
@@ -0,0 +1,43 @@
1
+ # The one check a pull request gets on a GitHub-hosted runner.
2
+ #
3
+ # The leak scan takes seconds and it is the gate that must run before a human
4
+ # looks at a diff: this repository is public and the tool talks to a Plex Media
5
+ # Server, so a token, a server address, an absolute path into somebody's music
6
+ # collection or a real library name in a diff is the failure that matters.
7
+ # Everything heavier — lint, the Python matrix, the generated-skill check — runs
8
+ # in `ci.yml` on the maintainer's self-hosted runner, on `main` rather than on
9
+ # pull requests.
10
+ #
11
+ # That is not a reduction in rigour, and a thin-looking PR check should not be
12
+ # read as one: every change goes through the local no-mistakes gate — review,
13
+ # tests, lint, docs — before the PR exists. GitHub-hosted CI is not the primary
14
+ # quality signal here. Keep this workflow to the one cheap job; adding the
15
+ # matrix back would restore the duplicate-run waste the split exists to end.
16
+ name: hygiene
17
+
18
+ on:
19
+ pull_request:
20
+
21
+ permissions:
22
+ contents: read
23
+
24
+ concurrency:
25
+ group: hygiene-${{ github.ref }}
26
+ cancel-in-progress: true
27
+
28
+ jobs:
29
+ leakcheck:
30
+ name: leak check
31
+ runs-on: ubuntu-latest
32
+ timeout-minutes: 10
33
+ steps:
34
+ - uses: actions/checkout@v4
35
+ - uses: actions/setup-python@v5
36
+ with:
37
+ python-version: "3.12"
38
+ # Prove the scanner still detects what it claims to before trusting it: a
39
+ # scanner that quietly stopped matching would otherwise pass every diff.
40
+ - name: Self-test against a synthetic dirty fixture
41
+ run: python3 scripts/leakcheck.py --demo
42
+ - name: Scan every tracked file
43
+ run: python3 scripts/leakcheck.py
@@ -0,0 +1,59 @@
1
+ name: release
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ # Drives version bumps and the changelog from conventional commits, and opens
13
+ # a release PR. Merging that PR is what cuts a release.
14
+ release-please:
15
+ name: release PR
16
+ runs-on: ubuntu-latest
17
+ permissions:
18
+ contents: write
19
+ pull-requests: write
20
+ outputs:
21
+ release_created: ${{ steps.release.outputs.release_created }}
22
+ tag_name: ${{ steps.release.outputs.tag_name }}
23
+ steps:
24
+ - uses: googleapis/release-please-action@v4
25
+ id: release
26
+ with:
27
+ config-file: release-please-config.json
28
+ manifest-file: .release-please-manifest.json
29
+
30
+ publish:
31
+ name: publish to PyPI
32
+ needs: release-please
33
+ if: needs.release-please.outputs.release_created == 'true'
34
+ runs-on: ubuntu-latest
35
+ environment:
36
+ name: pypi
37
+ url: https://pypi.org/p/plex-axi
38
+ permissions:
39
+ # The only credential involved: a short-lived OIDC token minted for this
40
+ # run. No long-lived PyPI token exists in this repository or anywhere else.
41
+ id-token: write
42
+ steps:
43
+ - uses: actions/checkout@v4
44
+ - uses: actions/setup-python@v5
45
+ with:
46
+ python-version: "3.12"
47
+ - run: python -m pip install --upgrade pip build
48
+ - run: python -m build
49
+
50
+ # Publishing a broken artifact is worse than not publishing: install what
51
+ # was actually built and confirm the entry point runs before it ships.
52
+ - name: Smoke-test the built wheel
53
+ run: |
54
+ python -m venv /tmp/verify
55
+ /tmp/verify/bin/pip install --quiet dist/*.whl
56
+ /tmp/verify/bin/plex-axi --version
57
+ /tmp/verify/bin/plex-axi search --help > /dev/null
58
+
59
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .coverage
11
+
12
+ # Agent-local settings, which routinely contain absolute home paths. Keeping
13
+ # this out of .git/info/exclude matters: that file is local to one clone and
14
+ # never travels with the repository.
15
+ .claude/
16
+ .codex/
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.1.0"
3
+ }
@@ -0,0 +1,350 @@
1
+ # Project agent memory
2
+
3
+ This file is the project's committed home for project-intrinsic agent knowledge: build, test,
4
+ release, architecture, and sharp-edge notes that should travel with the code.
5
+
6
+ ## The hard constraint: this repository is public and must stay generic
7
+
8
+ `plex-axi` talks to a Plex Media Server, and a music library is unusually rich in exactly the wrong
9
+ things: real artist and album names, absolute paths into somebody's collection, the server's
10
+ `machineIdentifier`, and a token that is a bearer credential for the whole library. Before writing
11
+ **anything** into this repo, including tests, fixtures, docs, examples and commit messages:
12
+
13
+ - **No host addresses.** No RFC1918 addresses, no `plex.direct` hostnames, no install-specific
14
+ hostnames or ports. The base URL comes from `PLEX_URL`.
15
+ - **No credentials.** A Plex token is a 20-character bearer credential; the newer flow issues JWTs
16
+ (`eyJ...`). Neither must ever appear in a commit, test, fixture, doc, example or log line.
17
+ - **No real library content.** No real artist, album or track names, and no real file paths. Invent
18
+ obviously-synthetic ones: `Example Artist`, `Example Album`, `http://plex.example.com:32400`.
19
+ - **No `machineIdentifier`s, local paths or personal identifiers.**
20
+
21
+ `scripts/leakcheck.py` enforces the half that has a shape — do not rely on remembering it:
22
+
23
+ ```sh
24
+ scripts/leakcheck.py # every tracked file
25
+ scripts/leakcheck.py --staged # what a commit would record (pre-commit hook)
26
+ scripts/leakcheck.py --commit-msg PATH # the message itself (commit-msg hook)
27
+ scripts/leakcheck.py --rules # the live rule list
28
+ scripts/leakcheck.py --demo # self-test: proves every rule still fires
29
+ scripts/install-hooks.sh # sets core.hooksPath to .githooks
30
+ ```
31
+
32
+ CI runs `--demo` before the real scan, so a scanner that stopped detecting anything fails the build
33
+ rather than passing silently. If the scanner flags a line that legitimately needs the shape, add
34
+ `leakcheck: allow=<rule>` on that line — scoped to that one rule, never blanket. Do not weaken a
35
+ rule to make a commit pass, and do not bypass the hooks.
36
+
37
+ **Real content has no shape and the scanner cannot see it.** Artist and album names are the half
38
+ that is convention only. The README says the coverage is bounded; do not restore any claim that the
39
+ guard makes review unnecessary.
40
+
41
+ **Writing tests for the guard:** build credential shapes at run time. `leakcheck.synthetic_jwt()`
42
+ base64-encodes a payload and `leakcheck.synthetic_plex_token()` joins fragments, because the
43
+ condensed pass joins the whole file before re-scanning and will (correctly) find a literal split
44
+ across lines.
45
+
46
+ **A `leakcheck: allow=` marker must survive `ruff format`.** The marker is matched on the physical
47
+ line the shape lands on, and the formatter moves a trailing comment when it wraps the expression it
48
+ was attached to — which silently un-marks the line. Keep such lines short enough not to wrap, and
49
+ re-run the scanner *after* formatting, not before. This has already bitten once.
50
+
51
+ ## Architecture
52
+
53
+ - `toon.py` — a strict TOON encoder (spec v4.1), taken unchanged from the sibling AXI project.
54
+ Encoding happens **only** at the output boundary; command modules return plain JSON-shaped dicts.
55
+ Do not loosen it to make output prettier.
56
+ - `output.py` — the single place anything reaches stdout, and therefore the only place redaction has
57
+ to hold. `HelpBlock` is the one deliberate departure from strict TOON: `help[N]:` blocks render one
58
+ suggestion per line, matching the AXI standard and the sibling AXI CLIs, because the suggestions
59
+ are command lines full of commas.
60
+ - `plex.py` — connection, hardening and error translation. Nothing from the client library crosses
61
+ this boundary: not its exceptions, not its response bodies, not its name.
62
+ - `music.py` — the product: section resolution, the per-field filter map, the search, the exact
63
+ count, and the row shapes.
64
+ - `ids.py` — which `plex://` string may be printed. See "The five `plex://` forms" below.
65
+ - `argspec.py` — per-subcommand flag declarations. Unknown flags are rejected by name with the valid
66
+ ones inlined; `RENAMED` maps plausible wrong guesses (mostly video vocabulary) to the real flag.
67
+ - `commands/` — one module per noun, each exposing `COMMAND_FOR(noun)` and
68
+ `run(ctx, noun, sub, parsed)`.
69
+
70
+ **Deviation from the sibling project, and why.** The sibling AXI project maps one module to one noun and exports a
71
+ single `COMMAND`. Here two modules each serve three nouns that differ only by a Plex libtype or
72
+ filter field — `genres`/`moods`/`styles`, and `track`/`album`/`artist` — so modules export
73
+ `COMMAND_FOR(noun)` instead. Three near-identical files per group would have been worse than one
74
+ parameterised one. Adding a noun is still one entry in `COMMAND_ORDER` and one in `_MODULES` in
75
+ `cli.py`; root help, `SKILL.md` and the parametrised test sweeps all derive from those.
76
+
77
+ ### Security invariants — do not regress these
78
+
79
+ - **Every output path is redacted, stdout and stderr alike.** `output.write`, `output.write_text`,
80
+ `output.debug` and `output.debug_exception` all pass through `redact()`. stderr is not a safe
81
+ channel just because agents ignore it: it reaches terminals, logs and CI output.
82
+ - **`cli.main` has a last-resort `except Exception`** that renders a structured, redacted error on
83
+ **stdout** and names only the exception *type*, never its message. Without it an unexpected
84
+ exception prints a raw traceback on stderr, bypassing redaction entirely and leaving stdout empty.
85
+ - **The token is registered as a secret in `config.load`**, at the moment it is read. It is rejected
86
+ there if it contains whitespace or a control character, because an HTTP client raises a
87
+ `ValueError` embedding the whole header when it finds one. A *trailing* newline is stripped
88
+ instead of refused: `PLEX_TOKEN=$(cat token.txt)` is the common case, not a mistake.
89
+ - **`redact()` has a token-shaped backstop as well as the registered literal.** `X-Plex-Token=` as a
90
+ URL parameter is redacted whether or not the value ever passed through this process's config,
91
+ because the client library appends it to artwork, stream and web URLs.
92
+ - **`api` refuses write methods.** A read-only tool with a POST escape hatch is not a read-only tool,
93
+ and several Plex write endpoints are destructive. It also refuses a caller-supplied
94
+ `--query X-Plex-Token=…`, which is how a credential reaches shell history.
95
+ - Tests for all of this live in `tests/test_credentials.py`, which asserts `capsys` **stderr** is
96
+ clean as often as stdout.
97
+
98
+ ## Sharp edges
99
+
100
+ Everything here was paid for once. Most of it is invisible until it is wrong.
101
+
102
+ - **There are two `search()` methods and only one of them works.** `Library.search` hits
103
+ `/library/all`, validates nothing, and drops unknown keyword arguments straight into the query
104
+ string; its own docstring says *"This is untested but seems to work. Use library section search
105
+ when you can."* Everything in this tool goes through `MusicSection.searchTracks/Albums/Artists`.
106
+ Never reach for `server.library.search`.
107
+ - **A numeric filter has three different meanings depending on how it is reached.**
108
+ `userRating__gte=8` through `Library.search` is emitted verbatim into the URL and applied nowhere.
109
+ Through `LibrarySection.search` it becomes a *client-side* post-filter applied after `limit` has
110
+ already sliced the results, so it filters within the slice rather than narrowing the query. Only
111
+ `filters={"userRating>": 8}` is a real, server-validated Plex predicate. `music._assert_server_side`
112
+ fails loudly if anything ever lands in the client-side bucket again.
113
+ - **Plex's operator suffixes are not Python's.** `>` normalises to `>=` ("is greater than or
114
+ equals") and `>>` to `>>=` ("is greater than"). The library validates the operator against the
115
+ field's advertised set and raises listing the valid ones; `music._filter_error` turns that into a
116
+ usable message rather than letting it escape.
117
+ - **`group` is not a field the server advertises — the client library adds it by hand.**
118
+ `FilteringType._manualFields` injects `('group', 'string', 'SQL Group By Statement')` into *every*
119
+ libtype, so `filters={"group": "title"}` validates on any server whatever its metadata says. That
120
+ means grouping can never fail validation, and it also means *nothing in the request can tell you
121
+ whether the server honoured it*. `music._verify_grouping` therefore checks the rows that came back
122
+ for repeated titles and says so when they are there. Do not replace that with a claim based on
123
+ what was asked.
124
+ - **Auto-reload is switched off, and it must stay off.** `PlexPartialObject.__getattribute__`
125
+ re-fetches the whole object whenever a requested attribute is `None`. On a list of twenty albums,
126
+ reading a field one of them happens not to carry is twenty extra HTTP requests, and the caller sees
127
+ only a slow command. `plex.py` sets `PLEXAPI_PLEXAPI_AUTORELOAD=false` before the import; an absent
128
+ value stays absent and is reported as `null`. The two places that genuinely need more data ask
129
+ outright: the detail views fetch `/library/metadata/<key>`, and `--check-files` reloads.
130
+ `tests/test_commands.py` asserts no list view ever fetches a per-item path.
131
+ - **`BASE_HEADERS` must be mutated in place, never rebound.** `plexapi.server` does
132
+ `from plexapi import BASE_HEADERS` at import time, so assigning `plexapi.BASE_HEADERS = …` leaves
133
+ the dict that is actually sent untouched — a near-miss that looks like it worked. `plex.harden`
134
+ clears and updates the existing dict.
135
+ - **The client library publishes the machine's identity by default.** `X-Plex-Device-Name` is the
136
+ operating system's hostname and `X-Plex-Client-Identifier` is derived from the MAC address, and
137
+ both end up in the server's device list. `harden()` replaces the first with `plex-axi` and the
138
+ second with a hash of the MAC — stable per machine, which is what Plex needs, without handing over
139
+ the address itself.
140
+ - **Three switches decide whether the token is disclosed, not one.** The environment variable read at
141
+ import time, the parsed config file, and the logging filter — plus `server._showSecrets`, which is
142
+ what `url()` consults. A tool that merely avoids `includeToken=True` has covered none of them.
143
+ `plex.harden` sets all four.
144
+ - **`accessible` and `exists` are empty unless you ask.** `PlexPartialObject._INCLUDES` sets
145
+ `checkFiles: 0`, so the server never stats the file on an ordinary fetch. `--check-files` is a
146
+ deliberate second round-trip, and with the flag off the detail view says **"not checked"** rather
147
+ than "not accessible": reporting the empty value as absence would be a different untruth from the
148
+ one it is avoiding.
149
+ - **A session element carries a `<User>` child and the library reads it unguarded.** A `/status/sessions`
150
+ response without one raises `AttributeError` inside `PlexSession._loadData` rather than parsing to
151
+ something empty. The test double models the real shape so this is exercised rather than assumed.
152
+ - **Sort fields are libtype-scoped on the wire.** `recentlyAddedAlbums` builds
153
+ `sort=album.addedAt:desc`, not `sort=addedAt:desc`. Anything parsing a sort parameter has to strip
154
+ the scope.
155
+ - **A tag filter costs an extra round-trip.** `_validateFieldValueTag` calls `listFilterChoices` to
156
+ map a genre *name* to the numeric id Plex filters on. That is why `--genre` is one request more
157
+ than `--year`, and it is also what makes `--genre Jazz` work at all.
158
+ - **Genres and styles live on the artist.** In a Plex music library a track carries no genre, so a
159
+ track-scoped genre filter returns nothing on a library tagged the ordinary way. `--genre` and
160
+ `--style` resolve to `artist.genre` and `artist.style` whatever the searched libtype is; `--mood`
161
+ scopes to the libtype, because Plex's analysis writes moods at every level.
162
+ - **Ratings are stars in both directions.** Plex stores 0–10; `--rated-min` takes 0–5 and every
163
+ rating printed is 0–5, so a value read out of one command can be passed into the next. Breaking
164
+ that symmetry would be a silent trap.
165
+ - **The exact count comes from a second request with an empty body.**
166
+ `X-Plex-Container-Size: 0` returns the container metadata and no rows. The server-side `limit`
167
+ parameter is deliberately *not* used for the page, because it also caps `totalSize` and would turn
168
+ the exact total into a lie; `maxresults` bounds the fetch instead.
169
+ - **Exit codes follow one rule.** A static invocation problem — unknown flag, unknown command, a
170
+ rating key that is not a number, a write method on `api` — exits 2. An outcome of a lookup against
171
+ live state — nothing at that rating key, no music library, an ambiguous section — exits 1. A zero
172
+ result from a well-formed search exits **0**: an empty answer is an answer.
173
+
174
+ ### The five `plex://` forms
175
+
176
+ Five strings are in circulation and they all look like one identifier:
177
+
178
+ | Form | Produced by | Safe to print as a media id? |
179
+ |---|---|---|
180
+ | `plex://<machineIdentifier>/<ratingKey>` | a media browser | **yes** — the canonical form, and the only one this tool emits |
181
+ | `plex://<ratingKey>` | older integrations | yes, but consumers call it the legacy branch |
182
+ | `plex://{<json>}` | play-queue dispatch | yes, matched before URL parsing |
183
+ | `plex://track/<ratingKey>` | a tool's internal id | **no** — parses as a server named `track` |
184
+ | `plex://track/<24-hex>` | the client library's own `guid` | **no** — raises `ValueError` in a consumer |
185
+
186
+ The last two are the trap: the same shape in two namespaces, one of which is a legitimate Plex
187
+ identifier handed out under the attribute name `guid`. `ids.media_content_id` builds only the first
188
+ form and refuses anything that is not a decimal rating key; `tests/test_ids.py` sweeps every command
189
+ and asserts none of them ever emits form 4 or 5.
190
+
191
+ **Labels are vendor-neutral, and the output stops at the identifier.** The field is `media_id`, not
192
+ the name of any particular consumer: this ships to anyone with a Plex library, and naming one in the
193
+ default output would be wrong for everyone else. There is deliberately no "play this with …" line
194
+ and no configuration for one. A template could only ever come from the operator, so printing it back
195
+ tells the caller nothing they did not already know — it was ceremony. The `item:` block is exactly
196
+ four fields — `media_id`, `rating_key`, `guid`, `note` — and `tests/test_ids.py` asserts that list
197
+ verbatim. The README explains in prose what consumes a `media_id`; that belongs in documentation,
198
+ not in output.
199
+
200
+ **`rating_key` is not stable.** It is a row number in one server's database and it moves when an
201
+ item is re-matched or the library is rebuilt. Every command that emits one emits the `guid` beside
202
+ it and says so; do not drop the note from any output a human might paste into a configuration file.
203
+
204
+ ## The command contract
205
+
206
+ `plex-axi api` reaches every readable Plex path, so a typed command is never justified by reach.
207
+ What the typed commands add is judgement.
208
+
209
+ **The promotion rule.** A command earns its place only when it does something `api` cannot — and the
210
+ PR must say what. The current set, each with its reason:
211
+
212
+ | Command | What `api` cannot do |
213
+ |---|---|
214
+ | `search` | build the filter expression at all: per-field scoping, operator normalisation and tag-id resolution are three round-trips and a validation pass |
215
+ | `genres` / `moods` / `styles` | know that the choices endpoint is per-field and per-libtype, and hand the list back as the recovery set on a miss |
216
+ | `track` / `album` / `artist` | the second round-trip behind `--check-files`, and the difference between "not checked" and "not accessible" |
217
+ | `similar` | surface `distance`, which is on the wire but meaningless without the seed's `analysis` version beside it |
218
+ | `recent` | pick the music-typed endpoint over the server-wide one that spans video |
219
+ | `sessions` | separate music sessions from the rest without the caller parsing types |
220
+ | `doctor` | check four things in the order they fail, and exit non-zero |
221
+
222
+ **Demotion.** If a typed command's body reduces to flag-mapping plus a request, delete it — the
223
+ measure is the diff, not the intention.
224
+
225
+ **What is deliberately absent, and will stay absent.** No playback of any kind, no speaker, room,
226
+ player, client or target concept, no video, no server administration, no metadata editing, no second
227
+ Plex client library, and no semantic name resolution by regex or substring. The first is enforced by
228
+ `tests/test_no_dispatch.py`; read its module docstring before touching it. The rule is not that the
229
+ tool *cannot* play music — the client library can, over a live cloud path to a Sonos and over a
230
+ local one to a Chromecast, and both are one attribute access away. It is that it *must not*, because
231
+ reaching past the seam makes two systems believe they own the same queue.
232
+
233
+ ## Build, test, lint
234
+
235
+ ```sh
236
+ pip install -e ".[dev]"
237
+ pytest # ~200 tests, a couple of seconds
238
+ ruff check . && ruff format --check .
239
+ plex-axi skill --check # SKILL.md is generated, never hand-edited
240
+ scripts/leakcheck.py # run this AFTER formatting
241
+ ```
242
+
243
+ **Tests never need a live server or a live token, and must not start to.** They run the real client
244
+ library against a Plex double in `tests/conftest.py` that speaks HTTP-shaped XML over a fake
245
+ `requests` session. That is the point: the claims worth testing — that a filter is applied
246
+ server-side, that the URL carries the operator Plex actually defines, that a count is exact — are
247
+ claims about the request the client library builds, and a double that only agreed with the client
248
+ could not test any of them.
249
+
250
+ **The double must answer like Plex, not like the client.** Two rules, neither optional:
251
+
252
+ - **Model the refusals.** `KNOWN_PARAMS` and `KNOWN_FIELDS` are an explicit allow-list; anything else
253
+ is a `400`. A filter that reached the URL in a spelling Plex does not define — which a permissive
254
+ server would ignore, returning a plausible unfiltered answer — is a refusal here. The tables are
255
+ deliberately *not* imported from `plex_axi`: a second opinion that is a copy of the first is not
256
+ one. A new parameter is refused until it is added to the table, and adding it is how the parameter
257
+ gets confirmed rather than assumed.
258
+ - **Apply the filters for real.** A request for tracks rated four stars and up returns only those
259
+ tracks, from the double's own predicate code. A double that returned the same rows whatever was
260
+ asked would let a filter that does nothing pass every test.
261
+
262
+ The double also models both answers to the open question about grouping (`FakePlex(groupable=…)`):
263
+ a server that collapses repeated titles and one that accepts the parameter and ignores it. Which one
264
+ a given Plex build is cannot be settled without a live server, so the tool is tested against both and
265
+ reports which one it met.
266
+
267
+ Supported Pythons are 3.10 through 3.12, and **the floor is not a free choice — `PlexAPI` sets
268
+ it.** plexapi 4.18.0 raised its own `requires-python` to `>=3.10`, which raised this project's floor
269
+ with it and said nothing: `requires-python = ">=3.9"` stayed in `pyproject.toml`, the whole test
270
+ suite passed on 3.10 through 3.12, and the only thing that ever noticed was `pip install` on 3.9
271
+ failing to resolve the dependency at all. Published metadata is the one claim tests cannot check,
272
+ because the interpreter that would have failed is the one they were never run on. **Read plexapi's
273
+ `requires-python` before raising the pin, and treat a bump as a possible floor change until you
274
+ have.** `tests/test_python_floor.py` now holds `requires-python`, the `ci.yml` matrix and the
275
+ `Programming Language :: Python :: X.Y` classifiers to one number, so drifting any one of them
276
+ apart fails locally rather than at somebody's install.
277
+
278
+ Ruff's `target-version` is deliberately **still `py39`**, which is the one place the floor is stated
279
+ and was not moved with it. Raising it to `py310` enables `B905` (`zip()` without `strict=`), whose
280
+ only site is `commands/similar.py` — a correct fix, `rows_for` is 1:1 over its input, but a runtime
281
+ behaviour change to a shipped command, which does not belong in a change that moves published
282
+ metadata. Bump it in its own commit and take the `strict=True` with it. Leaving it low is safe
283
+ meanwhile: `target-version` only bounds which upgrades ruff proposes, and a low one is conservative,
284
+ never wrong.
285
+
286
+ `from __future__ import annotations` stays at the top of every module. It is no longer what makes
287
+ `X | None` safe — that is native from 3.10 — but it keeps annotations lazy and the modules uniform,
288
+ and removing 23 of them is a separate change from moving a floor. Note that nested quotes inside a
289
+ multi-line f-string expression need 3.12; the test fixtures use `.format` for that reason.
290
+
291
+ `skills/plex-axi/SKILL.md` is generated from the CLI's command table. Change the commands, then run
292
+ `plex-axi skill` and commit the result; CI fails if the two disagree.
293
+
294
+ ## Continuous integration
295
+
296
+ Three workflows, split by where the work is cheap:
297
+
298
+ - **`.github/workflows/ci.yml`** — the heavy matrix (leak scan, lint, `pytest` on 3.10 through 3.12,
299
+ the generated-skill check) on the maintainer's self-hosted runner. Triggers: push to `main`, a
300
+ nightly `schedule`, and `workflow_dispatch`. Never pull requests.
301
+ - **`.github/workflows/hygiene.yml`** — the leak scan alone, on `ubuntu-latest`, on `pull_request`.
302
+ Exactly one GitHub-hosted check per PR, and it takes seconds.
303
+ - **`.github/workflows/release.yml`** — GitHub-hosted, and to stay that way: OIDC trusted publishing
304
+ needs `id-token: write` on a GitHub-hosted runner.
305
+
306
+ **`ci.yml` must never gain a `pull_request` trigger.** This repository is public and the runner is
307
+ the maintainer's own workstation. Every trigger it has requires write access, so fork-submitted code
308
+ cannot reach the machine; `pull_request` would hand any contributor on the internet code execution on
309
+ it, in one line, with no other visible symptom. The reasoning is repeated at the top of the file so
310
+ it survives someone later "helpfully" adding PR coverage.
311
+
312
+ **A thin PR check is the design, not an oversight.** Every change goes through the local no-mistakes
313
+ gate — review, tests, lint, docs — before a PR is opened, so GitHub-hosted CI is not the primary
314
+ quality signal here. Do not add jobs to `hygiene.yml` to make pull requests look better covered.
315
+
316
+ **A self-hosted runner runs as a real user, and that user's `~/.local` is on every job's path.** A
317
+ bare `pytest`, `ruff` or `plex-axi` would therefore run the maintainer's copy against whatever
318
+ checkout it points at. Every job that needs third-party packages does
319
+ `python -m venv --clear .venv` and calls tools as `.venv/bin/<tool>`; a venv sets
320
+ `ENABLE_USER_SITE = False`, so the leak cannot happen. Do not "simplify" these back to bare names.
321
+
322
+ Checkouts on the self-hosted runner pass `persist-credentials: false`: that workspace outlives the
323
+ job, and a token left behind in its `.git/config` would outlive it too.
324
+
325
+ The nightly cron deliberately avoids 08:17 and 09:41 UTC, which sibling projects' self-hosted
326
+ workflows hold on the same workstation.
327
+
328
+ ## Releasing
329
+
330
+ release-please owns the version. `.release-please-manifest.json` records the **last released**
331
+ version, which is not the same thing as the version in `pyproject.toml` and
332
+ `src/plex_axi/__init__.py` — those hold the version a release will *write*. During bootstrap, before
333
+ the first publish, the manifest deliberately trails the source: baseline `0.0.0` with source `0.1.0`
334
+ means "nothing released yet, the next `feat:` lands 0.1.0". Never "fix" a mismatch by raising the
335
+ baseline to match the source; that tells release-please the version is already out and it bumps past
336
+ it, permanently skipping a version number PyPI will never let us reuse.
337
+
338
+ ## Licensing
339
+
340
+ MIT. Two constraints that came out of surveying the landscape and still hold:
341
+
342
+ - **Tautulli is GPL-3.** It solved some of the same problems — its rating-key remapping machinery is
343
+ the best evidence that keys move — but not one line of its code, tests or fixtures may be copied
344
+ here. Ideas only.
345
+ - **Two of the more interesting prior-art projects ship no licence at all**, which means all rights
346
+ reserved. Their *shapes* are ideas and reading them is fine; their expression is not available.
347
+
348
+ The client library is BSD-3-Clause and the community OpenAPI specification that documents Plex's
349
+ media-query language is MIT; both are compatible, and the spec is the thing to cite rather than
350
+ re-derive.