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.
- emby_cli-0.5.4/AGENTS.md +295 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/CHANGELOG.md +16 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/PKG-INFO +5 -4
- {emby_cli-0.5.2 → emby_cli-0.5.4}/README.md +4 -3
- {emby_cli-0.5.2 → emby_cli-0.5.4}/pyproject.toml +4 -1
- emby_cli-0.5.4/src/emby_cli/__init__.py +14 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/_version.py +2 -2
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/cli.py +18 -2
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/client.py +52 -14
- emby_cli-0.5.4/tests/test_auth_expired.py +71 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_client_retry.py +8 -5
- emby_cli-0.5.4/tests/test_urllib3_warning.py +22 -0
- emby_cli-0.5.2/src/emby_cli/__init__.py +0 -6
- {emby_cli-0.5.2 → emby_cli-0.5.4}/.env.example +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/.github/workflows/ci.yml +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/.github/workflows/publish.yml +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/.gitignore +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/LICENSE +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/__main__.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/auth_cache.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/commands/__init__.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/commands/config.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/commands/download.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/commands/help.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/commands/info.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/commands/login.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/commands/logout.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/commands/play.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/commands/search.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/commands/show.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/commands/version.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/constants.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/credentials.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/download_ops.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/mode_args.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/output.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/resolve.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/types.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/util.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/src/emby_cli/version.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_auth_cache.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_client.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_config.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_credentials.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_download_library.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_download_one.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_download_ops.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_info.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_logout.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_output.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_parser.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_play.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_resolve.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_resolve_table.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_resolve_titles.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_search.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_session_auth.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_show.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_util.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_validate_args.py +0 -0
- {emby_cli-0.5.2 → emby_cli-0.5.4}/tests/test_version.py +0 -0
emby_cli-0.5.4/AGENTS.md
ADDED
|
@@ -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.
|
|
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
|
|
66
|
+
**3. Search and play:**
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
69
|
emby-cli search --item "matrix"
|
|
70
|
-
emby-cli
|
|
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
|
|
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
|
|
31
|
+
**3. Search and play:**
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
34
|
emby-cli search --item "matrix"
|
|
35
|
-
emby-cli
|
|
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
|
|
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
|
-
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 5,
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 == []
|
|
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
|
|
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
|
|
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
|