emby-cli 0.5.2__tar.gz → 0.5.4__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 (61) hide show
  1. emby_cli-0.5.4/AGENTS.md +295 -0
  2. {emby_cli-0.5.2 → emby_cli-0.5.4}/CHANGELOG.md +16 -0
  3. {emby_cli-0.5.2 → emby_cli-0.5.4}/PKG-INFO +5 -4
  4. {emby_cli-0.5.2 → emby_cli-0.5.4}/README.md +4 -3
  5. {emby_cli-0.5.2 → emby_cli-0.5.4}/pyproject.toml +4 -1
  6. emby_cli-0.5.4/src/emby_cli/__init__.py +14 -0
  7. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/_version.py +2 -2
  8. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/cli.py +18 -2
  9. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/client.py +52 -14
  10. emby_cli-0.5.4/tests/test_auth_expired.py +71 -0
  11. {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_client_retry.py +8 -5
  12. emby_cli-0.5.4/tests/test_urllib3_warning.py +22 -0
  13. emby_cli-0.5.2/src/emby_cli/__init__.py +0 -6
  14. {emby_cli-0.5.2 → emby_cli-0.5.4}/.env.example +0 -0
  15. {emby_cli-0.5.2 → emby_cli-0.5.4}/.github/workflows/ci.yml +0 -0
  16. {emby_cli-0.5.2 → emby_cli-0.5.4}/.github/workflows/publish.yml +0 -0
  17. {emby_cli-0.5.2 → emby_cli-0.5.4}/.gitignore +0 -0
  18. {emby_cli-0.5.2 → emby_cli-0.5.4}/LICENSE +0 -0
  19. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/__main__.py +0 -0
  20. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/auth_cache.py +0 -0
  21. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/commands/__init__.py +0 -0
  22. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/commands/config.py +0 -0
  23. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/commands/download.py +0 -0
  24. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/commands/help.py +0 -0
  25. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/commands/info.py +0 -0
  26. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/commands/login.py +0 -0
  27. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/commands/logout.py +0 -0
  28. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/commands/play.py +0 -0
  29. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/commands/search.py +0 -0
  30. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/commands/show.py +0 -0
  31. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/commands/version.py +0 -0
  32. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/constants.py +0 -0
  33. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/credentials.py +0 -0
  34. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/download_ops.py +0 -0
  35. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/mode_args.py +0 -0
  36. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/output.py +0 -0
  37. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/resolve.py +0 -0
  38. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/types.py +0 -0
  39. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/util.py +0 -0
  40. {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/version.py +0 -0
  41. {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_auth_cache.py +0 -0
  42. {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_client.py +0 -0
  43. {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_config.py +0 -0
  44. {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_credentials.py +0 -0
  45. {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_download_library.py +0 -0
  46. {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_download_one.py +0 -0
  47. {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_download_ops.py +0 -0
  48. {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_info.py +0 -0
  49. {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_logout.py +0 -0
  50. {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_output.py +0 -0
  51. {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_parser.py +0 -0
  52. {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_play.py +0 -0
  53. {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_resolve.py +0 -0
  54. {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_resolve_table.py +0 -0
  55. {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_resolve_titles.py +0 -0
  56. {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_search.py +0 -0
  57. {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_session_auth.py +0 -0
  58. {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_show.py +0 -0
  59. {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_util.py +0 -0
  60. {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_validate_args.py +0 -0
  61. {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_version.py +0 -0
@@ -0,0 +1,295 @@
1
+ # AGENTS.md — emby-cli
2
+
3
+ Guide for humans and AI agents contributing to **[emby-cli](https://github.com/soukron/emby-cli)**.
4
+ Read this **before** changing application code, tests, CI, or release workflow.
5
+
6
+ End-user documentation lives in [`README.md`](README.md). This file is for **maintainers and contributors**.
7
+
8
+ ---
9
+
10
+ ## What this project is
11
+
12
+ CLI to **search**, **inspect** (`show`), **play**, and **download/backup** original media files from an **Emby** server over its REST API (typically HTTP port `8096`). No SSH, no shared mounts, no rsync.
13
+
14
+ | | |
15
+ |--|--|
16
+ | Package name | `emby-cli` on [PyPI](https://pypi.org/project/emby-cli/) |
17
+ | Python | ≥ 3.9 (CI: 3.9 / 3.11 / 3.12) |
18
+ | Build / version | hatchling + **hatch-vcs** (version from git tags `v*`) |
19
+ | License | CC-BY-NC-4.0 |
20
+ | Entrypoint | `emby-cli` → `emby_cli.cli:main` |
21
+
22
+ ---
23
+
24
+ ## Repository layout
25
+
26
+ ```
27
+ .
28
+ ├── pyproject.toml # package metadata, ruff, pytest, hatch
29
+ ├── README.md # public user docs (keep host-agnostic)
30
+ ├── CHANGELOG.md # user-facing release notes
31
+ ├── LICENSE
32
+ ├── .env.example # env var names only (no secrets)
33
+ ├── AGENTS.md # this file
34
+ ├── src/emby_cli/ # application package
35
+ │ ├── cli.py # argparse + main() + pre-auth validation
36
+ │ ├── client.py # EmbyClient: HTTP, auth, browse, download/HLS
37
+ │ ├── auth_cache.py # auth.json contexts (kubeconfig-style)
38
+ │ ├── credentials.py # resolve server / user / password
39
+ │ ├── mode_args.py # --item / --library / QUERY helpers
40
+ │ ├── resolve.py # title lines, pick_best, choice tables
41
+ │ ├── download_ops.py # download loop, skip, library matching
42
+ │ ├── output.py # stdout/stderr messages + Stats / exit codes
43
+ │ ├── util.py # paths, sizes, skip, ffmpeg remux
44
+ │ ├── constants.py # retries, types, field lists, CLIENT_NAME
45
+ │ ├── types.py # TypedDict shapes for Emby JSON (documentation)
46
+ │ ├── commands/ # one module per subcommand
47
+ │ └── …
48
+ ├── tests/ # pytest; no real Emby server
49
+ └── .github/workflows/
50
+ ├── ci.yml # ruff + pytest matrix
51
+ └── publish.yml # tag v* → PyPI (Trusted Publisher)
52
+ ```
53
+
54
+ `src/emby_cli/_version.py` is generated by hatch-vcs (gitignored). Do not edit it by hand.
55
+
56
+ ---
57
+
58
+ ## Local development
59
+
60
+ ```bash
61
+ git clone https://github.com/soukron/emby-cli.git
62
+ cd emby-cli
63
+ python3 -m venv .venv
64
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
65
+ pip install -e ".[dev]"
66
+ cp .env.example .env # optional; fill EMBY_* for manual smoke tests
67
+ ```
68
+
69
+ | Command | Purpose |
70
+ |---------|---------|
71
+ | `pytest` / `pytest -qvv` | Run the suite |
72
+ | `ruff check .` | Lint (also runs in CI) |
73
+ | `python -m build` | Wheel + sdist |
74
+ | `twine check dist/*` | Metadata sanity |
75
+ | `emby-cli …` / `python -m emby_cli …` | Run the CLI |
76
+
77
+ Editable install uses hatch `dev-mode-dirs = ["src"]`. Changes under `src/emby_cli/` are picked up on the next process start. Pytest sets `pythonpath = ["src"]` so tests always import the working tree.
78
+
79
+ Re-run `pip install -e ".[dev]"` when dependencies or `[project.scripts]` change.
80
+
81
+ ### Environment variables
82
+
83
+ The CLI reads **`os.environ` only** (it does not auto-load `.env`; export vars or `set -a && source .env && set +a`).
84
+
85
+ | Variable | Role |
86
+ |----------|------|
87
+ | `EMBY_SERVER` | Base URL (active context after `login` / `config use-server` if unset) |
88
+ | `EMBY_API_KEY` | API key (alternative to username/password) |
89
+ | `EMBY_USERNAME` / `EMBY_PASSWORD` | Name-based auth |
90
+ | `EMBY_OUTPUT` | Download destination (default `./downloads`) |
91
+ | `EMBY_ITEM_ID` | Default `--id` for `download --item` / `play` |
92
+ | `EMBY_METHOD` | `download` \| `stream` \| `hls` |
93
+ | `EMBY_PLAYER` | External player command for `play` |
94
+ | `EMBY_CACHE_DIR` | Credential store dir (default `~/.cache/emby-cli`; file `auth.json`) |
95
+ | `EMBY_NO_AUTH_CACHE` | `1` = do not read/write session cache |
96
+
97
+ Never commit `.env`, tokens, passwords, or stream URLs that embed credentials.
98
+
99
+ ---
100
+
101
+ ## Architecture (where logic lives)
102
+
103
+ ```text
104
+ cli.main
105
+ ├─ validate_*_args # reject bad selectors BEFORE auth
106
+ ├─ open EmbyClient + ensure session
107
+ └─ commands.<cmd>
108
+ ├─ validate_*_args # intentional duplicate (defense in depth)
109
+ └─ EmbyClient / resolve / download_ops / output
110
+ ```
111
+
112
+ | Concern | Primary modules |
113
+ |---------|-----------------|
114
+ | HTTP, retries, auth, browse, binary/HLS download | `client.py` |
115
+ | Session store on disk | `auth_cache.py` |
116
+ | Resolving server/user/password from flags/env/TTY/cache | `credentials.py` |
117
+ | `--item` / `--library` / embedded QUERY | `mode_args.py` + command modules |
118
+ | Title-line resolution (`Movie (2010)`, `Show S01E02`) | `resolve.py` |
119
+ | Download orchestration, skip, library match | `download_ops.py` |
120
+ | User-visible messages and exit codes | `output.py` |
121
+ | New flag or subcommand | `cli.py` + `commands/…` + `help.COMMAND_SUMMARIES` |
122
+
123
+ `client.py` is large on purpose today (HTTP + Emby ops + download). Prefer small, tested helpers over a big rewrite unless a dedicated refactor is planned. See historical notes in maintainer discussions; do not split every endpoint into its own file without need.
124
+
125
+ ---
126
+
127
+ ## Auth and session cache (important)
128
+
129
+ - **API key** (`-k` / `EMBY_API_KEY`): never written to disk.
130
+ - **Username/password**: single store `{EMBY_CACHE_DIR}/auth.json` with `contexts[]` + `current_context` (kubeconfig-style). **Password is never stored.** Legacy `*.cache` files migrate automatically.
131
+ - `login` — authenticate, upsert context, activate it.
132
+ - `logout` — `POST /Sessions/Logout` when possible, then remove context.
133
+ - `config` — `current-server`, `get-servers`, `use-server`, `view` (tokens redacted).
134
+ - Other commands: no `--server` → active context; cache hit → reuse token; miss + credentials → transparent login; **HTTP 401** with password available → invalidate cache and re-authenticate **once per top-level request** (no infinite reauth loop).
135
+ - If 401 cannot be recovered (no password, or re-login rejected): raise `AuthenticationError`, clear the stale cache entry, and let `main` print a short stderr message (no traceback).
136
+ - Invalid selectors for `search` / `download` / `play` / `show` are rejected **before** opening a client.
137
+
138
+ ### Intentional duplicate validation
139
+
140
+ `main()` calls `validate_*_args`, and each `cmd_*` validates again. **Do not merge these.** Pre-auth rejection in `main` plus a safety net if a command is invoked directly.
141
+
142
+ ---
143
+
144
+ ## CLI contracts (do not break casually)
145
+
146
+ Observable behavior for users and scripts: flags, stdout/stderr split, exit codes, and message prefixes. Prefer CHANGELOG entries when any of these change.
147
+
148
+ ### Modes `--item` / `--library`
149
+
150
+ Shared pattern (`mode_args.py`) for **`search`** and **`download`**:
151
+
152
+ | Flag | Behavior |
153
+ |------|----------|
154
+ | `--item [QUERY]` | Media mode; QUERY optional on the same flag. Alias: `--media-item` |
155
+ | `--library [QUERY]` | Library mode; QUERY optional on the same flag |
156
+ | `--id` | Emby ID (alternative to QUERY; CSV allowed where documented) |
157
+ | `--search` | Alternative QUERY (do not combine with QUERY embedded in `--item`/`--library`) |
158
+
159
+ `nargs='?'` + `const=""` so `--item --all` / `--library --id X` do not swallow the next flag as QUERY.
160
+
161
+ `show` is different: `--item`/`--library` plus **mandatory `--id`** (no QUERY / `--search`).
162
+
163
+ ### Output streams
164
+
165
+ | Stream | Content |
166
+ |--------|---------|
167
+ | **stdout** | Tables, progress, `download (method):`, `skip:`, `Done. ok=…`, disambiguation candidate tables |
168
+ | **stderr** | `error: …`, validation failures, fatal messages |
169
+
170
+ `print_error()` must write to stderr. Scripts should be able to pipe stdout as data.
171
+
172
+ ### List sorting
173
+
174
+ Any user-facing list/table of items or libraries: sort by **Id descending**, then **Name** alphabetically (case-insensitive). Use `resolve.sort_for_display`.
175
+
176
+ ### Title resolution (`play` / `download --item` / `--from-file`)
177
+
178
+ Strict by default: year with no match, multiple series, or multiple versions → fail with a table + hint.
179
+
180
+ `--pick-best-item`: prefer ≤1080p via `pick_best_item` (non-4K preferred). Season-only `Sxx` without `Exx`: download may fetch the whole season (`allow_season_all`); play refuses. Library QUERY uses substring + disambiguation (`match_libraries`).
181
+
182
+ ### Download methods (`-m` / `EMBY_METHOD`)
183
+
184
+ | Method | Behavior |
185
+ |--------|----------|
186
+ | `download` | `GET /Items/{id}/Download` — best for “original” backup |
187
+ | `stream` | PlaybackInfo + `original.*` (Emby Web style) |
188
+ | `hls` | TS segments remuxed to `.mkv` via bundled `static-ffmpeg` — **not** bit-identical |
189
+
190
+ Identity headers / User-Agent: `emby-cli/<version>` (`constants.CLIENT_NAME`). `EMBY_SERVER` may include or omit `/emby` (normalized).
191
+
192
+ ### Exit codes
193
+
194
+ - `download`: non-zero if `error > 0`; with `--from-file` also if `not_found > 0`.
195
+ - `--dry-run` still runs the path; planned items count as `ok` in the Done summary.
196
+
197
+ ---
198
+
199
+ ## Testing rules
200
+
201
+ 1. **No real Emby server** in automated tests. Mock `session.request` / `session.get` / client methods.
202
+ 2. Patch `time.sleep` in retry tests; assert attempt counts and backoff values explicitly.
203
+ 3. Prefer tests that would **fail if production logic is deleted** (mutation-minded): assert file contents, headers (including new AccessToken after reauth), `clear_auth_cache` calls, stderr vs stdout.
204
+ 4. Do not mock away the unit under test (e.g. do not mock `authenticate` when testing reauth end-to-end).
205
+ 5. After behavior changes affecting streams or exit codes, update tests to use `capsys.readouterr().err` / `.out` as appropriate.
206
+
207
+ ```bash
208
+ pytest -qvv
209
+ ruff check .
210
+ ```
211
+
212
+ CI runs both on every push/PR.
213
+
214
+ ---
215
+
216
+ ## Coding conventions
217
+
218
+ - Keep changes **scoped**: one concern per PR/commit when practical.
219
+ - Do not invent a DI framework, async rewrite, or swap `requests` without a strong reason.
220
+ - Do not remove the README **Responsible use** section.
221
+ - Do not put private hostnames, internal paths, or deployment layout into `README.md`.
222
+ - Never log API keys, passwords, or authenticated DirectStream URLs.
223
+ - HLS is not a bit-exact backup; document that if you change download UX.
224
+ - Prefer extending existing helpers (`_retry_response`, `download_items`, `sort_for_display`, …) over copying patterns.
225
+
226
+ ### Things that look redundant but are intentional
227
+
228
+ | Pattern | Why |
229
+ |---------|-----|
230
+ | Double `validate_*_args` | Pre-auth gate + direct-call safety |
231
+ | Thin `search_items()` wrapper | Convenient API; keep unless clearly unused |
232
+ | Legacy auth-cache aliases (if still present) | Migrate carefully; do not break imports without CHANGELOG |
233
+
234
+ ---
235
+
236
+ ## Git and release (PyPI only)
237
+
238
+ Version comes from **git tags** `vX.Y.Z` via hatch-vcs. Do not hardcode the version in `pyproject.toml`.
239
+
240
+ **Do not create GitHub Releases** for this project. Distribution is **PyPI only**. Tag + `publish.yml` is enough.
241
+
242
+ ### Release checklist
243
+
244
+ 1. Move `CHANGELOG.md` `## Unreleased` → `## X.Y.Z` (user-visible notes).
245
+ 2. Ensure the working tree is clean.
246
+ 3. Tag and push:
247
+
248
+ ```bash
249
+ git tag -a "vX.Y.Z" -m "Release vX.Y.Z"
250
+ git push origin HEAD
251
+ git push origin "vX.Y.Z"
252
+ ```
253
+
254
+ 4. Watch `.github/workflows/publish.yml` (GitHub Environment `pypi` + Trusted Publisher).
255
+ 5. If CI already uploaded the wheel and a manual `twine upload` fails with **400 file-name-reuse**, upload **only the `.tar.gz`**.
256
+
257
+ Maintainers with a local wrapper Makefile may use an equivalent `make release VERSION=X.Y.Z`; the git operations above are the source of truth inside this repository.
258
+
259
+ ---
260
+
261
+ ## Common mistakes
262
+
263
+ 1. Committing secrets or a filled `.env`.
264
+ 2. Putting deployment/host details in the public README.
265
+ 3. Removing *Responsible use*.
266
+ 4. Editing `_version.py` or the version field by hand.
267
+ 5. Unifying the duplicate validators “for cleanliness”.
268
+ 6. Assuming HLS equals an original file copy.
269
+ 7. Running tests against a real Emby and calling that CI.
270
+ 8. Creating a GitHub Release when only a PyPI tag was intended.
271
+ 9. Changing stdout/stderr or exit codes without updating tests and CHANGELOG.
272
+
273
+ ---
274
+
275
+ ## Typical tasks → files
276
+
277
+ | Task | Start here |
278
+ |------|------------|
279
+ | Auth, retry, browse, download/HLS | `client.py` |
280
+ | Item/library QUERY modes | `mode_args.py`, `cli.py`, `commands/{search,show,download,play}.py` |
281
+ | New subcommand or flag | `cli.py`, `commands/…`, `help.COMMAND_SUMMARIES` |
282
+ | `show` detail / library recents | `commands/show.py`, `constants.SHOW_*` |
283
+ | Title disambiguation / pick-best | `resolve.py` |
284
+ | Skip / dry-run / library download loop | `download_ops.py` |
285
+ | Message text / exit codes | `output.py` |
286
+ | Emby response typing | `types.py` (TypedDict; apply gradually) |
287
+ | User docs | `README.md` |
288
+ | Release notes | `CHANGELOG.md` |
289
+
290
+ ---
291
+
292
+ ## Related docs
293
+
294
+ - Users: [`README.md`](README.md), [`CHANGELOG.md`](CHANGELOG.md)
295
+ - License: [`LICENSE`](LICENSE) (CC-BY-NC-4.0)
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.4
4
+
5
+ Fixed:
6
+
7
+ - macOS: suppress urllib3 `NotOpenSSLWarning` (system Python + LibreSSL) by filtering before `requests` is imported — the previous filter in `cli.py` ran too late because `__init__.py` already pulled in urllib3.
8
+
9
+ ## 0.5.3
10
+
11
+ Fixed:
12
+
13
+ - Expired/revoked sessions: print a clear `error:` on stderr (suggest `emby-cli login`) instead of an `HTTPError` traceback; drop the stale AccessToken from the local cache.
14
+
15
+ Docs:
16
+
17
+ - Public `AGENTS.md` for contributors (layout, auth, CLI contracts, tests, release).
18
+
3
19
  ## 0.5.2
4
20
 
5
21
  Changed:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: emby-cli
3
- Version: 0.5.2
3
+ Version: 0.5.4
4
4
  Summary: CLI to list, search, play, and download/backup original media from an Emby server via its REST API.
5
5
  Project-URL: Homepage, https://github.com/soukron/emby-cli
6
6
  Project-URL: Repository, https://github.com/soukron/emby-cli
@@ -63,11 +63,11 @@ Your session is saved locally. Later commands reuse it, so you usually do not ne
63
63
  emby-cli info
64
64
  ```
65
65
 
66
- **3. Search and download:**
66
+ **3. Search and play:**
67
67
 
68
68
  ```bash
69
69
  emby-cli search --item "matrix"
70
- emby-cli download --item "matrix (1999)" --pick-best-item
70
+ emby-cli play --item "matrix (1999)" --pick-best-item
71
71
  ```
72
72
 
73
73
  Use `emby-cli help` for the command list, and `emby-cli <command> -h` for options.
@@ -222,10 +222,11 @@ Flags override environment variables. Optional template: `.env.example` (export
222
222
  python3 -m venv .venv && source .venv/bin/activate
223
223
  pip install -e ".[dev]"
224
224
  pytest -q
225
+ ruff check .
225
226
  python -m build && twine check dist/*
226
227
  ```
227
228
 
228
- Releases: tag `vX.Y.Z` and push. CI publishes to PyPI via Trusted Publisher.
229
+ Architecture, CLI contracts, testing rules, and release process: see **[AGENTS.md](AGENTS.md)** (for humans and AI agents). Releases: tag `vX.Y.Z` and push — CI publishes to PyPI only (no GitHub Releases).
229
230
 
230
231
  ---
231
232
 
@@ -28,11 +28,11 @@ Your session is saved locally. Later commands reuse it, so you usually do not ne
28
28
  emby-cli info
29
29
  ```
30
30
 
31
- **3. Search and download:**
31
+ **3. Search and play:**
32
32
 
33
33
  ```bash
34
34
  emby-cli search --item "matrix"
35
- emby-cli download --item "matrix (1999)" --pick-best-item
35
+ emby-cli play --item "matrix (1999)" --pick-best-item
36
36
  ```
37
37
 
38
38
  Use `emby-cli help` for the command list, and `emby-cli <command> -h` for options.
@@ -187,10 +187,11 @@ Flags override environment variables. Optional template: `.env.example` (export
187
187
  python3 -m venv .venv && source .venv/bin/activate
188
188
  pip install -e ".[dev]"
189
189
  pytest -q
190
+ ruff check .
190
191
  python -m build && twine check dist/*
191
192
  ```
192
193
 
193
- Releases: tag `vX.Y.Z` and push. CI publishes to PyPI via Trusted Publisher.
194
+ Architecture, CLI contracts, testing rules, and release process: see **[AGENTS.md](AGENTS.md)** (for humans and AI agents). Releases: tag `vX.Y.Z` and push — CI publishes to PyPI only (no GitHub Releases).
194
195
 
195
196
  ---
196
197
 
@@ -76,4 +76,7 @@ target-version = "py39"
76
76
  select = ["E4", "E7", "E9", "F", "I", "B", "UP"]
77
77
 
78
78
  [tool.ruff.lint.per-file-ignores]
79
- "src/emby_cli/cli.py" = ["E402"] # warnings.filterwarnings antes de import requests (intencional)
79
+ # warnings.filterwarnings before requests/urllib3 (macOS LibreSSL NotOpenSSLWarning)
80
+ "src/emby_cli/__init__.py" = ["E402"]
81
+ "src/emby_cli/cli.py" = ["E402"]
82
+ "src/emby_cli/client.py" = ["E402"]
@@ -0,0 +1,14 @@
1
+ """emby-cli — backup and stream media from an Emby server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+
7
+ # macOS system Python uses LibreSSL; urllib3 v2 only warns (TLS still works).
8
+ # Must run before any import that pulls in requests/urllib3 (see client.py).
9
+ warnings.filterwarnings("ignore", message="urllib3 v2 only supports OpenSSL")
10
+
11
+ from emby_cli.client import EmbyClient
12
+ from emby_cli.version import get_version
13
+
14
+ __all__ = ["EmbyClient", "get_version"]
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.5.2'
22
- __version_tuple__ = version_tuple = (0, 5, 2)
21
+ __version__ = version = '0.5.4'
22
+ __version_tuple__ = version_tuple = (0, 5, 4)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -8,11 +8,12 @@ import sys
8
8
  import warnings
9
9
 
10
10
  # macOS system Python ships LibreSSL; urllib3 v2 only warns, TLS still works.
11
+ # Also set in __init__.py / client.py — package import can load urllib3 first.
11
12
  warnings.filterwarnings("ignore", message="urllib3 v2 only supports OpenSSL")
12
13
 
13
14
  import requests
14
15
 
15
- from emby_cli.client import EmbyClient
16
+ from emby_cli.client import AuthenticationError, EmbyClient
16
17
  from emby_cli.commands.config import cmd_config
17
18
  from emby_cli.commands.download import cmd_download, validate_download_args
18
19
  from emby_cli.commands.help import COMMAND_SUMMARIES, cmd_help
@@ -291,7 +292,18 @@ def _open_client(args: argparse.Namespace) -> EmbyClient:
291
292
  file=sys.stderr,
292
293
  )
293
294
  sys.exit(1)
295
+ except AuthenticationError as exc:
296
+ print(f"error: {exc}", file=sys.stderr)
297
+ sys.exit(1)
294
298
  except requests.HTTPError as exc:
299
+ resp = getattr(exc, "response", None)
300
+ if resp is not None and resp.status_code in (401, 403):
301
+ print(f"error: {exc}", file=sys.stderr)
302
+ print(
303
+ "Run `emby-cli login` (or pass a valid --api-key) and try again.",
304
+ file=sys.stderr,
305
+ )
306
+ sys.exit(1)
295
307
  print(f"Authentication failed: {exc}", file=sys.stderr)
296
308
  sys.exit(1)
297
309
  return client
@@ -349,7 +361,11 @@ def main() -> None:
349
361
  "play": cmd_play,
350
362
  "show": cmd_show,
351
363
  }
352
- commands[args.command](client, args)
364
+ try:
365
+ commands[args.command](client, args)
366
+ except AuthenticationError as exc:
367
+ print(f"error: {exc}", file=sys.stderr)
368
+ sys.exit(1)
353
369
 
354
370
 
355
371
  if __name__ == "__main__":
@@ -5,10 +5,14 @@ from __future__ import annotations
5
5
  import hashlib
6
6
  import shutil
7
7
  import time
8
+ import warnings
8
9
  from pathlib import Path
9
10
  from typing import Callable
10
11
  from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
11
12
 
13
+ # Before requests/urllib3 (macOS LibreSSL → NotOpenSSLWarning).
14
+ warnings.filterwarnings("ignore", message="urllib3 v2 only supports OpenSSL")
15
+
12
16
  import m3u8
13
17
  import requests
14
18
  from tqdm import tqdm
@@ -35,6 +39,16 @@ from emby_cli.version import get_version
35
39
  _DEVICE_ID = hashlib.md5(CLIENT_NAME.encode()).hexdigest()
36
40
  _AUTH_PATH = "/Users/AuthenticateByName"
37
41
 
42
+ _AUTH_EXPIRED_MSG = (
43
+ "Session expired or credentials were rejected by the server. "
44
+ "Run `emby-cli login` (or pass a valid --api-key) and try again."
45
+ )
46
+
47
+
48
+ class AuthenticationError(RuntimeError):
49
+ """AccessToken / credentials no longer accepted (HTTP 401 on Emby)."""
50
+
51
+
38
52
 
39
53
  class _RetryImmediately(Exception):
40
54
  """Internal signal for a retry that does not need backoff."""
@@ -185,19 +199,30 @@ class EmbyClient:
185
199
  reauth_attempted = True
186
200
  return self._try_reauthenticate()
187
201
 
188
- return _retry_response(
189
- request,
190
- max_attempts=max_attempts,
191
- connection_message=lambda attempt, total, exc: (
192
- f" Connection error (attempt {attempt}/{total}), retrying in "
193
- f"{RETRY_BACKOFF_BASE * (2 ** (attempt - 1))}s: {exc}"
194
- ),
195
- server_message=lambda status, attempt, total: (
196
- f" Server error {status} (attempt {attempt}/{total}), retrying in "
197
- f"{RETRY_BACKOFF_BASE * (2 ** (attempt - 1))}s"
198
- ),
199
- retry_http_error=retry_after_reauth,
200
- )
202
+ try:
203
+ return _retry_response(
204
+ request,
205
+ max_attempts=max_attempts,
206
+ connection_message=lambda attempt, total, exc: (
207
+ f" Connection error (attempt {attempt}/{total}), retrying in "
208
+ f"{RETRY_BACKOFF_BASE * (2 ** (attempt - 1))}s: {exc}"
209
+ ),
210
+ server_message=lambda status, attempt, total: (
211
+ f" Server error {status} (attempt {attempt}/{total}), retrying in "
212
+ f"{RETRY_BACKOFF_BASE * (2 ** (attempt - 1))}s"
213
+ ),
214
+ retry_http_error=retry_after_reauth,
215
+ )
216
+ except requests.HTTPError as exc:
217
+ resp = getattr(exc, "response", None)
218
+ if (
219
+ resp is not None
220
+ and resp.status_code == 401
221
+ and path != _AUTH_PATH
222
+ ):
223
+ self._discard_rejected_session()
224
+ raise AuthenticationError(_AUTH_EXPIRED_MSG) from exc
225
+ raise
201
226
 
202
227
  def _get(
203
228
  self,
@@ -330,11 +355,24 @@ class EmbyClient:
330
355
  self.access_token = self.api_key
331
356
  self.user_id = None
332
357
 
358
+ def _discard_rejected_session(self) -> None:
359
+ """Drop a rejected AccessToken from memory and disk (best effort)."""
360
+ if self.api_key and self.access_token == self.api_key:
361
+ # API-key auth: nothing useful to clear from the user cache.
362
+ return
363
+ self._invalidate_cached_session()
364
+
333
365
  def _try_reauthenticate(self) -> bool:
334
366
  if self._username is None or self._password is None:
335
367
  return False
336
368
  self._invalidate_cached_session()
337
- self.authenticate(self._username, self._password)
369
+ try:
370
+ self.authenticate(self._username, self._password)
371
+ except requests.HTTPError as exc:
372
+ resp = getattr(exc, "response", None)
373
+ if resp is not None and resp.status_code in (401, 403):
374
+ return False
375
+ raise
338
376
  return True
339
377
 
340
378
  def logout_session(self) -> None:
@@ -0,0 +1,71 @@
1
+ """UX when a cached AccessToken is rejected (401)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ import pytest
8
+ import requests
9
+
10
+ from emby_cli.auth_cache import AuthCacheEntry, load_auth_cache, save_auth_cache
11
+ from emby_cli.cli import main
12
+ from emby_cli.client import AuthenticationError, EmbyClient
13
+
14
+
15
+ def _resp(status_code):
16
+ r = MagicMock()
17
+ r.status_code = status_code
18
+ if status_code >= 400:
19
+ r.raise_for_status.side_effect = requests.HTTPError(response=r)
20
+ else:
21
+ r.raise_for_status.return_value = None
22
+ return r
23
+
24
+
25
+ def test_stale_cache_401_raises_auth_error_and_clears_store(tmp_path, monkeypatch):
26
+ monkeypatch.setenv("EMBY_CACHE_DIR", str(tmp_path))
27
+ save_auth_cache(
28
+ AuthCacheEntry.create("http://host:8096", "alice", "stale-tok", "uid-1")
29
+ )
30
+ client = EmbyClient("http://host:8096")
31
+ client.ensure_user_session("alice", None) # restore cache; no password for reauth
32
+ assert client.access_token == "stale-tok"
33
+
34
+ with patch.object(client.session, "request", return_value=_resp(401)):
35
+ with pytest.raises(AuthenticationError, match="emby-cli login"):
36
+ client._get("/Users/uid-1/Views")
37
+
38
+ assert load_auth_cache(server_url="http://host:8096", username="alice") is None
39
+ assert client.access_token is None
40
+
41
+
42
+ def test_main_prints_friendly_message_on_expired_session(capsys, monkeypatch, tmp_path):
43
+ monkeypatch.setenv("EMBY_CACHE_DIR", str(tmp_path))
44
+ save_auth_cache(
45
+ AuthCacheEntry.create("http://host:8096", "alice", "stale-tok", "uid-1")
46
+ )
47
+ monkeypatch.setattr(
48
+ "sys.argv",
49
+ ["emby-cli", "--server", "http://host:8096", "search", "--library", "--all"],
50
+ )
51
+
52
+ client = EmbyClient("http://host:8096", use_auth_cache=True)
53
+ client.access_token = "stale-tok"
54
+ client.user_id = "uid-1"
55
+ client._username = "alice"
56
+
57
+ with (
58
+ patch("emby_cli.cli._open_client", return_value=client),
59
+ patch.object(client, "get_libraries", side_effect=AuthenticationError(
60
+ "Session expired or credentials were rejected by the server. "
61
+ "Run `emby-cli login` (or pass a valid --api-key) and try again."
62
+ )),
63
+ ):
64
+ with pytest.raises(SystemExit) as exc:
65
+ main()
66
+
67
+ assert exc.value.code == 1
68
+ err = capsys.readouterr().err
69
+ assert "error: Session expired" in err
70
+ assert "emby-cli login" in err
71
+ assert "Traceback" not in err
@@ -7,7 +7,7 @@ from unittest.mock import MagicMock, call, patch
7
7
  import pytest
8
8
  import requests
9
9
 
10
- from emby_cli.client import _AUTH_PATH, EmbyClient
10
+ from emby_cli.client import _AUTH_PATH, AuthenticationError, EmbyClient
11
11
  from emby_cli.constants import MAX_RETRIES, RETRY_BACKOFF_BASE
12
12
 
13
13
  # ── helpers ──────────────────────────────────────────────────────────────
@@ -276,10 +276,9 @@ class TestHttp401Reauth:
276
276
  patch("emby_cli.client.time.sleep"),
277
277
  patch("emby_cli.client.clear_auth_cache"),
278
278
  ):
279
- with pytest.raises(requests.HTTPError) as exc_info:
279
+ with pytest.raises(AuthenticationError, match="Session expired"):
280
280
  c._get("/System/Info")
281
281
 
282
- assert exc_info.value.response.status_code == 401
283
282
  assert req.call_count == 3
284
283
 
285
284
  def test_next_request_can_reauthenticate_again(self):
@@ -320,12 +319,16 @@ class TestHttp401Reauth:
320
319
  with (
321
320
  patch.object(c.session, "request", side_effect=[_resp(401)]) as req,
322
321
  patch("emby_cli.client.time.sleep") as sleep,
322
+ patch("emby_cli.client.clear_auth_cache") as clear_cache,
323
323
  ):
324
- with pytest.raises(requests.HTTPError):
324
+ with pytest.raises(AuthenticationError, match="Session expired"):
325
325
  c._get("/Items")
326
326
 
327
327
  assert req.call_count == 1
328
328
  sleep.assert_not_called()
329
+ clear_cache.assert_called_once_with(
330
+ server_url="http://host:8096", username="user"
331
+ )
329
332
 
330
333
  def test_no_reauth_without_username(self):
331
334
  c = EmbyClient("http://host:8096", use_auth_cache=False)
@@ -337,7 +340,7 @@ class TestHttp401Reauth:
337
340
  patch.object(c.session, "request", side_effect=[_resp(401)]) as req,
338
341
  patch("emby_cli.client.time.sleep") as sleep,
339
342
  ):
340
- with pytest.raises(requests.HTTPError):
343
+ with pytest.raises(AuthenticationError, match="Session expired"):
341
344
  c._get("/Items")
342
345
 
343
346
  assert req.call_count == 1
@@ -0,0 +1,22 @@
1
+ """macOS LibreSSL: urllib3 NotOpenSSLWarning must not reach the user."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+
7
+
8
+ def test_libressl_warning_message_is_filtered():
9
+ """The ignore filter used by emby_cli must catch urllib3's LibreSSL notice."""
10
+ with warnings.catch_warnings(record=True) as recorded:
11
+ warnings.simplefilter("always")
12
+ # Same rule as __init__.py / client.py / cli.py (after simplefilter).
13
+ warnings.filterwarnings("ignore", message="urllib3 v2 only supports OpenSSL")
14
+ warnings.warn(
15
+ "urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' "
16
+ "module is compiled with 'LibreSSL 2.8.3'. See: "
17
+ "https://github.com/urllib3/urllib3/issues/3020",
18
+ UserWarning,
19
+ stacklevel=2,
20
+ )
21
+
22
+ assert recorded == []
@@ -1,6 +0,0 @@
1
- """emby-cli — backup and stream media from an Emby server."""
2
-
3
- from emby_cli.client import EmbyClient
4
- from emby_cli.version import get_version
5
-
6
- __all__ = ["EmbyClient", "get_version"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes