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.
- plex_axi-0.1.0/.githooks/commit-msg +16 -0
- plex_axi-0.1.0/.githooks/pre-commit +17 -0
- plex_axi-0.1.0/.github/workflows/ci.yml +149 -0
- plex_axi-0.1.0/.github/workflows/hygiene.yml +43 -0
- plex_axi-0.1.0/.github/workflows/release.yml +59 -0
- plex_axi-0.1.0/.gitignore +16 -0
- plex_axi-0.1.0/.release-please-manifest.json +3 -0
- plex_axi-0.1.0/AGENTS.md +350 -0
- plex_axi-0.1.0/CHANGELOG.md +13 -0
- plex_axi-0.1.0/CLAUDE.md +1 -0
- plex_axi-0.1.0/LICENSE +21 -0
- plex_axi-0.1.0/PKG-INFO +216 -0
- plex_axi-0.1.0/README.md +191 -0
- plex_axi-0.1.0/pyproject.toml +67 -0
- plex_axi-0.1.0/release-please-config.json +12 -0
- plex_axi-0.1.0/scripts/install-hooks.sh +11 -0
- plex_axi-0.1.0/scripts/leakcheck.py +651 -0
- plex_axi-0.1.0/skills/plex-axi/SKILL.md +241 -0
- plex_axi-0.1.0/src/plex_axi/__init__.py +6 -0
- plex_axi-0.1.0/src/plex_axi/__main__.py +10 -0
- plex_axi-0.1.0/src/plex_axi/argspec.py +312 -0
- plex_axi-0.1.0/src/plex_axi/cli.py +483 -0
- plex_axi-0.1.0/src/plex_axi/commands/__init__.py +13 -0
- plex_axi-0.1.0/src/plex_axi/commands/_common.py +116 -0
- plex_axi-0.1.0/src/plex_axi/commands/api.py +205 -0
- plex_axi-0.1.0/src/plex_axi/commands/doctor.py +146 -0
- plex_axi-0.1.0/src/plex_axi/commands/genres.py +124 -0
- plex_axi-0.1.0/src/plex_axi/commands/home.py +158 -0
- plex_axi-0.1.0/src/plex_axi/commands/item.py +328 -0
- plex_axi-0.1.0/src/plex_axi/commands/recent.py +96 -0
- plex_axi-0.1.0/src/plex_axi/commands/search.py +210 -0
- plex_axi-0.1.0/src/plex_axi/commands/sessions.py +80 -0
- plex_axi-0.1.0/src/plex_axi/commands/similar.py +169 -0
- plex_axi-0.1.0/src/plex_axi/commands/skill.py +78 -0
- plex_axi-0.1.0/src/plex_axi/config.py +183 -0
- plex_axi-0.1.0/src/plex_axi/errors.py +55 -0
- plex_axi-0.1.0/src/plex_axi/ids.py +129 -0
- plex_axi-0.1.0/src/plex_axi/music.py +574 -0
- plex_axi-0.1.0/src/plex_axi/output.py +254 -0
- plex_axi-0.1.0/src/plex_axi/plex.py +284 -0
- plex_axi-0.1.0/src/plex_axi/skill.py +137 -0
- plex_axi-0.1.0/src/plex_axi/toon.py +311 -0
- plex_axi-0.1.0/tests/conftest.py +1068 -0
- plex_axi-0.1.0/tests/test_cli.py +206 -0
- plex_axi-0.1.0/tests/test_commands.py +287 -0
- plex_axi-0.1.0/tests/test_config.py +76 -0
- plex_axi-0.1.0/tests/test_credentials.py +127 -0
- plex_axi-0.1.0/tests/test_doctor.py +142 -0
- plex_axi-0.1.0/tests/test_ids.py +127 -0
- plex_axi-0.1.0/tests/test_leakcheck.py +344 -0
- plex_axi-0.1.0/tests/test_no_dispatch.py +191 -0
- plex_axi-0.1.0/tests/test_output.py +85 -0
- plex_axi-0.1.0/tests/test_python_floor.py +110 -0
- plex_axi-0.1.0/tests/test_search.py +273 -0
- plex_axi-0.1.0/tests/test_skill.py +65 -0
- plex_axi-0.1.0/tests/test_toon.py +139 -0
- 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/
|
plex_axi-0.1.0/AGENTS.md
ADDED
|
@@ -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.
|