emby-cli 0.7.0__tar.gz → 0.7.2__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.7.0 → emby_cli-0.7.2}/AGENTS.md +5 -3
- {emby_cli-0.7.0 → emby_cli-0.7.2}/CHANGELOG.md +17 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/PKG-INFO +4 -1
- {emby_cli-0.7.0 → emby_cli-0.7.2}/README.md +3 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/_version.py +2 -2
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/auth_cache.py +72 -6
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/cli.py +18 -1
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/commands/config.py +32 -4
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/commands/item.py +1 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/item_ops.py +91 -17
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_auth_cache.py +43 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_config.py +70 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_item_ops.py +61 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/.env.example +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/.github/workflows/ci.yml +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/.github/workflows/publish.yml +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/.gitignore +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/LICENSE +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/pyproject.toml +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/__init__.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/__main__.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/api/__init__.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/api/collections.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/api/items.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/api/libraries.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/client.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/collection_ops.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/commands/__init__.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/commands/collection.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/commands/help.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/commands/info.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/commands/library.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/commands/login.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/commands/logout.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/commands/version.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/constants.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/credentials.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/data_cache.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/detail_output.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/download_ops.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/library_ops.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/media_sort.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/output.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/resolve.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/types.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/util.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/version.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_auth_expired.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_client.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_client_collection.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_client_item.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_client_library.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_client_retry.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_collection.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_collection_ops.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_credentials.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_data_cache.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_download_one.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_download_ops.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_info.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_item.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_item_performance.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_library.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_library_ops.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_logout.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_media_sort.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_output.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_parser.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_resolve.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_resolve_table.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_resolve_titles.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_session_auth.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_urllib3_warning.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_util.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_validate_args.py +0 -0
- {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_version.py +0 -0
|
@@ -248,7 +248,7 @@ on `client.items`; add `api/people.py` only if person lookup/creation needs
|
|
|
248
248
|
- **Username/password**: single store `{EMBY_CACHE_DIR}/auth.json` with `contexts[]` + `current_context` (kubeconfig-style). **Password is never stored.** Legacy `*.cache` files migrate automatically.
|
|
249
249
|
- `login` — authenticate, upsert context, activate it.
|
|
250
250
|
- `logout` — `POST /Sessions/Logout` when possible, then remove context.
|
|
251
|
-
- `config` — `current-server`, `get-servers`, `use-server`, `view` (tokens redacted).
|
|
251
|
+
- `config` — `current-server`, `get-servers`, `use-server`, `rename-server`, `view` (tokens redacted). Each context may have an optional `alias` (friendly name); `current_context` still stores the canonical `user@url` name.
|
|
252
252
|
- 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).
|
|
253
253
|
- 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).
|
|
254
254
|
- Invalid selectors for `item` / `library` / `collection` are rejected **before** opening a client.
|
|
@@ -352,8 +352,10 @@ Read-only entity commands (`item list`, `item search`, `item show`, `item play`)
|
|
|
352
352
|
Supports `--player`, `--wait`, `--pick-best-item`, `--no-cache`, `--no-parse-query`,
|
|
353
353
|
and comma-separated `--id`. Parent `--id` may appear before the subcommand.
|
|
354
354
|
QUERY defaults to title-line resolution (`resolve_title_items`).
|
|
355
|
-
- `item download` resolves one item by QUERY or `--id` (CSV supported)
|
|
356
|
-
|
|
355
|
+
- `item download` resolves one item by QUERY or `--id` (CSV supported). A
|
|
356
|
+
`--id` that refers to a `Series` downloads all episodes in season/episode
|
|
357
|
+
order. Downloads via `item_ops` (`download_items`, `download_item_ids`,
|
|
358
|
+
`download_from_file`).
|
|
357
359
|
Supports `--output`, `--method`, `--force`, `--throttle`, `--pick-best-item`,
|
|
358
360
|
`--dry-run`, `--from-file`, `--no-parse-query`, and optional `--mirror-path`.
|
|
359
361
|
QUERY / `--from-file` default to title-line resolution (`resolve_title_items`).
|
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.7.2
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `item download --id` expands a `Series` ID into all episodes (sorted by
|
|
10
|
+
season/episode, then Id) and downloads them sequentially. Duplicate
|
|
11
|
+
SxxExx versions follow the same rules as title-line downloads
|
|
12
|
+
(`--pick-best-item` or fail with a disambiguation table).
|
|
13
|
+
|
|
14
|
+
## 0.7.1
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `config rename-server NAME --new-name ALIAS` to assign a friendly alias to a
|
|
19
|
+
saved server. Aliases appear in `config get-servers` and work with
|
|
20
|
+
`config use-server` and `config current-server`.
|
|
21
|
+
|
|
5
22
|
## 0.7.0
|
|
6
23
|
|
|
7
24
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: emby-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
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
|
|
@@ -120,6 +120,7 @@ Preview a large operation first, then choose a destination:
|
|
|
120
120
|
```bash
|
|
121
121
|
emby-cli item download "breaking bad S01E01" --dry-run
|
|
122
122
|
emby-cli item download --id 123456 --output ./downloads
|
|
123
|
+
emby-cli item download --id 123456 --pick-best-item # series: one file per SxxExx
|
|
123
124
|
emby-cli item download --from-file titles.txt --dry-run
|
|
124
125
|
emby-cli library download "peliculas 4k" --output ./downloads
|
|
125
126
|
```
|
|
@@ -207,6 +208,8 @@ or environment variables.
|
|
|
207
208
|
emby-cli config get-servers
|
|
208
209
|
emby-cli config current-server
|
|
209
210
|
emby-cli config use-server 'you@http://emby:8096'
|
|
211
|
+
emby-cli config rename-server 'you@http://emby:8096' --new-name synology
|
|
212
|
+
emby-cli config use-server synology # alias from rename-server
|
|
210
213
|
emby-cli config view # tokens are redacted
|
|
211
214
|
emby-cli logout # revoke and forget the current session
|
|
212
215
|
```
|
|
@@ -85,6 +85,7 @@ Preview a large operation first, then choose a destination:
|
|
|
85
85
|
```bash
|
|
86
86
|
emby-cli item download "breaking bad S01E01" --dry-run
|
|
87
87
|
emby-cli item download --id 123456 --output ./downloads
|
|
88
|
+
emby-cli item download --id 123456 --pick-best-item # series: one file per SxxExx
|
|
88
89
|
emby-cli item download --from-file titles.txt --dry-run
|
|
89
90
|
emby-cli library download "peliculas 4k" --output ./downloads
|
|
90
91
|
```
|
|
@@ -172,6 +173,8 @@ or environment variables.
|
|
|
172
173
|
emby-cli config get-servers
|
|
173
174
|
emby-cli config current-server
|
|
174
175
|
emby-cli config use-server 'you@http://emby:8096'
|
|
176
|
+
emby-cli config rename-server 'you@http://emby:8096' --new-name synology
|
|
177
|
+
emby-cli config use-server synology # alias from rename-server
|
|
175
178
|
emby-cli config view # tokens are redacted
|
|
176
179
|
emby-cli logout # revoke and forget the current session
|
|
177
180
|
```
|
|
@@ -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.7.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 7,
|
|
21
|
+
__version__ = version = '0.7.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 7, 2)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -17,11 +17,13 @@ class AuthCacheEntry:
|
|
|
17
17
|
user_id: str
|
|
18
18
|
saved_at: str
|
|
19
19
|
name: str = ""
|
|
20
|
+
alias: str = ""
|
|
20
21
|
|
|
21
22
|
def __post_init__(self) -> None:
|
|
22
23
|
self.server_url = self.server_url.rstrip("/")
|
|
23
24
|
if not self.name:
|
|
24
25
|
self.name = context_name(self.server_url, self.username)
|
|
26
|
+
self.alias = self.alias.strip()
|
|
25
27
|
|
|
26
28
|
@classmethod
|
|
27
29
|
def create(
|
|
@@ -51,6 +53,32 @@ def context_name(server_url: str, username: str) -> str:
|
|
|
51
53
|
return f"{username}@{server_url.rstrip('/')}"
|
|
52
54
|
|
|
53
55
|
|
|
56
|
+
def display_name(entry: AuthCacheEntry) -> str:
|
|
57
|
+
"""Return the user-facing server label (alias if set, else canonical name)."""
|
|
58
|
+
return entry.alias or entry.name
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def validate_alias(alias: str, *, exclude_name: str | None = None) -> str | None:
|
|
62
|
+
"""Return an error message if *alias* is invalid; else ``None``."""
|
|
63
|
+
text = alias.strip()
|
|
64
|
+
if not text:
|
|
65
|
+
return "alias must not be empty"
|
|
66
|
+
if any(ch.isspace() for ch in text):
|
|
67
|
+
return "alias must not contain whitespace"
|
|
68
|
+
if "@" in text:
|
|
69
|
+
return "alias must not contain '@'"
|
|
70
|
+
store = load_store()
|
|
71
|
+
needle = text.casefold()
|
|
72
|
+
for entry in store.contexts:
|
|
73
|
+
if exclude_name and entry.name == exclude_name:
|
|
74
|
+
continue
|
|
75
|
+
if entry.alias and entry.alias.casefold() == needle:
|
|
76
|
+
return f"alias '{text}' is already used"
|
|
77
|
+
if entry.name.casefold() == needle:
|
|
78
|
+
return f"alias '{text}' conflicts with server name '{entry.name}'"
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
54
82
|
def cache_dir() -> Path:
|
|
55
83
|
return Path(os.environ.get("EMBY_CACHE_DIR", Path.home() / ".cache" / "emby-cli"))
|
|
56
84
|
|
|
@@ -72,6 +100,7 @@ def _entry_from_dict(data: dict) -> AuthCacheEntry | None:
|
|
|
72
100
|
user_id=str(data["user_id"]),
|
|
73
101
|
saved_at=str(data.get("saved_at") or ""),
|
|
74
102
|
name=str(data.get("name") or ""),
|
|
103
|
+
alias=str(data.get("alias") or ""),
|
|
75
104
|
)
|
|
76
105
|
except (KeyError, TypeError, ValueError):
|
|
77
106
|
return None
|
|
@@ -188,13 +217,17 @@ def upsert_context(entry: AuthCacheEntry, *, activate: bool = True) -> None:
|
|
|
188
217
|
if _auth_cache_disabled():
|
|
189
218
|
return
|
|
190
219
|
store = load_store()
|
|
220
|
+
canonical = context_name(entry.server_url, entry.username)
|
|
221
|
+
existing = next((c for c in store.contexts if c.name == canonical), None)
|
|
222
|
+
preserved_alias = existing.alias if existing else entry.alias
|
|
191
223
|
entry = AuthCacheEntry(
|
|
192
224
|
server_url=entry.server_url,
|
|
193
225
|
username=entry.username,
|
|
194
226
|
access_token=entry.access_token,
|
|
195
227
|
user_id=entry.user_id,
|
|
196
228
|
saved_at=entry.saved_at or datetime.now(timezone.utc).isoformat(),
|
|
197
|
-
name=
|
|
229
|
+
name=canonical,
|
|
230
|
+
alias=preserved_alias,
|
|
198
231
|
)
|
|
199
232
|
store.contexts = [c for c in store.contexts if c.name != entry.name]
|
|
200
233
|
store.contexts.append(entry)
|
|
@@ -232,16 +265,25 @@ def remove_context(
|
|
|
232
265
|
return True
|
|
233
266
|
|
|
234
267
|
|
|
235
|
-
def
|
|
236
|
-
"""
|
|
268
|
+
def resolve_context(name: str) -> AuthCacheEntry:
|
|
269
|
+
"""Resolve a server selector to exactly one cached context."""
|
|
237
270
|
store = load_store()
|
|
238
271
|
needle = name.strip()
|
|
272
|
+
if not needle:
|
|
273
|
+
raise KeyError("context '' not found")
|
|
274
|
+
|
|
239
275
|
matches = [c for c in store.contexts if c.name == needle]
|
|
240
276
|
if not matches:
|
|
241
|
-
|
|
277
|
+
folded = needle.casefold()
|
|
278
|
+
matches = [
|
|
279
|
+
c for c in store.contexts
|
|
280
|
+
if c.alias and c.alias.casefold() == folded
|
|
281
|
+
]
|
|
282
|
+
if not matches:
|
|
242
283
|
by_server = [
|
|
243
284
|
c for c in store.contexts
|
|
244
|
-
if c.server_url == needle.rstrip("/")
|
|
285
|
+
if c.server_url == needle.rstrip("/")
|
|
286
|
+
or c.name.endswith(f"@{needle.rstrip('/')}")
|
|
245
287
|
]
|
|
246
288
|
if len(by_server) == 1:
|
|
247
289
|
matches = by_server
|
|
@@ -254,12 +296,36 @@ def set_current_context(name: str) -> AuthCacheEntry:
|
|
|
254
296
|
matches = by_user_server
|
|
255
297
|
if len(matches) != 1:
|
|
256
298
|
raise KeyError(f"context '{name}' not found")
|
|
257
|
-
|
|
299
|
+
return matches[0]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def set_current_context(name: str) -> AuthCacheEntry:
|
|
303
|
+
"""Set ``current_context`` by alias, canonical name, or unique server URL."""
|
|
304
|
+
store = load_store()
|
|
305
|
+
entry = resolve_context(name)
|
|
258
306
|
store.current_context = entry.name
|
|
259
307
|
save_store(store)
|
|
260
308
|
return entry
|
|
261
309
|
|
|
262
310
|
|
|
311
|
+
def rename_context_alias(name: str, alias: str) -> AuthCacheEntry:
|
|
312
|
+
"""Assign a friendly *alias* to the context selected by *name*."""
|
|
313
|
+
if _auth_cache_disabled():
|
|
314
|
+
raise RuntimeError("auth cache is disabled")
|
|
315
|
+
store = load_store()
|
|
316
|
+
entry = resolve_context(name)
|
|
317
|
+
err = validate_alias(alias, exclude_name=entry.name)
|
|
318
|
+
if err:
|
|
319
|
+
raise ValueError(err)
|
|
320
|
+
for ctx in store.contexts:
|
|
321
|
+
if ctx.name == entry.name:
|
|
322
|
+
ctx.alias = alias.strip()
|
|
323
|
+
entry = ctx
|
|
324
|
+
break
|
|
325
|
+
save_store(store)
|
|
326
|
+
return entry
|
|
327
|
+
|
|
328
|
+
|
|
263
329
|
def load_auth_cache(
|
|
264
330
|
*,
|
|
265
331
|
server_url: str,
|
|
@@ -160,10 +160,24 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
160
160
|
help="Set the current server in the credentials file",
|
|
161
161
|
)
|
|
162
162
|
use_srv.add_argument(
|
|
163
|
+
"server_name",
|
|
164
|
+
metavar="NAME",
|
|
165
|
+
help="Server alias, entry name (user@url), or unique server URL",
|
|
166
|
+
)
|
|
167
|
+
rename_srv = cfg_sub.add_parser(
|
|
168
|
+
"rename-server",
|
|
169
|
+
help="Set a friendly alias for a saved server",
|
|
170
|
+
)
|
|
171
|
+
rename_srv.add_argument(
|
|
163
172
|
"server_name",
|
|
164
173
|
metavar="NAME",
|
|
165
174
|
help="Server entry name (user@url) or unique server URL",
|
|
166
175
|
)
|
|
176
|
+
rename_srv.add_argument(
|
|
177
|
+
"--new-name",
|
|
178
|
+
required=True,
|
|
179
|
+
help="Short alias used by get-servers and use-server",
|
|
180
|
+
)
|
|
167
181
|
cfg_sub.add_parser(
|
|
168
182
|
"view",
|
|
169
183
|
help="Display credentials file (tokens redacted)",
|
|
@@ -526,7 +540,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
526
540
|
|
|
527
541
|
it_download = it_sub.add_parser("download", help="Download media items")
|
|
528
542
|
it_download.add_argument("query", nargs="?", metavar="QUERY")
|
|
529
|
-
it_download.add_argument(
|
|
543
|
+
it_download.add_argument(
|
|
544
|
+
"--id",
|
|
545
|
+
help="Media item ID (comma-separated). Series IDs download all episodes",
|
|
546
|
+
)
|
|
530
547
|
it_download.add_argument(
|
|
531
548
|
"--type",
|
|
532
549
|
dest="item_type",
|
|
@@ -8,9 +8,11 @@ import sys
|
|
|
8
8
|
|
|
9
9
|
from emby_cli.auth_cache import (
|
|
10
10
|
auth_store_path,
|
|
11
|
+
display_name,
|
|
11
12
|
get_active_entry,
|
|
12
13
|
list_contexts,
|
|
13
14
|
load_store,
|
|
15
|
+
rename_context_alias,
|
|
14
16
|
set_current_context,
|
|
15
17
|
)
|
|
16
18
|
|
|
@@ -20,7 +22,7 @@ def cmd_config_current_server(_args: argparse.Namespace) -> None:
|
|
|
20
22
|
if active is None:
|
|
21
23
|
print("error: no current server", file=sys.stderr)
|
|
22
24
|
sys.exit(1)
|
|
23
|
-
print(active
|
|
25
|
+
print(display_name(active))
|
|
24
26
|
|
|
25
27
|
|
|
26
28
|
def cmd_config_get_servers(_args: argparse.Namespace) -> None:
|
|
@@ -33,7 +35,7 @@ def cmd_config_get_servers(_args: argparse.Namespace) -> None:
|
|
|
33
35
|
rows = []
|
|
34
36
|
for ctx in contexts:
|
|
35
37
|
current = "*" if ctx.name == store.current_context else ""
|
|
36
|
-
rows.append((current, ctx
|
|
38
|
+
rows.append((current, display_name(ctx), ctx.server_url, ctx.username))
|
|
37
39
|
|
|
38
40
|
headers = ("CURRENT", "NAME", "SERVER", "USER")
|
|
39
41
|
widths = [len(h) for h in headers]
|
|
@@ -59,7 +61,31 @@ def cmd_config_use_server(args: argparse.Namespace) -> None:
|
|
|
59
61
|
except KeyError as exc:
|
|
60
62
|
print(f"error: {exc}", file=sys.stderr)
|
|
61
63
|
sys.exit(1)
|
|
62
|
-
|
|
64
|
+
label = display_name(entry)
|
|
65
|
+
if label == entry.name:
|
|
66
|
+
print(f"Switched to server {entry.name}")
|
|
67
|
+
else:
|
|
68
|
+
print(f'Switched to server "{label}" ({entry.name})')
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cmd_config_rename_server(args: argparse.Namespace) -> None:
|
|
72
|
+
selector = (getattr(args, "server_name", None) or "").strip()
|
|
73
|
+
alias = (getattr(args, "new_name", None) or "").strip()
|
|
74
|
+
if not selector:
|
|
75
|
+
print("error: provide a server name", file=sys.stderr)
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
if not alias:
|
|
78
|
+
print("error: provide --new-name", file=sys.stderr)
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
try:
|
|
81
|
+
entry = rename_context_alias(selector, alias)
|
|
82
|
+
except KeyError as exc:
|
|
83
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
84
|
+
sys.exit(1)
|
|
85
|
+
except ValueError as exc:
|
|
86
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
87
|
+
sys.exit(1)
|
|
88
|
+
print(f'Renamed server "{entry.name}" to "{display_name(entry)}"')
|
|
63
89
|
|
|
64
90
|
|
|
65
91
|
def cmd_config_view(_args: argparse.Namespace) -> None:
|
|
@@ -70,6 +96,7 @@ def cmd_config_view(_args: argparse.Namespace) -> None:
|
|
|
70
96
|
"contexts": [
|
|
71
97
|
{
|
|
72
98
|
"name": c.name,
|
|
99
|
+
"alias": c.alias or None,
|
|
73
100
|
"server_url": c.server_url,
|
|
74
101
|
"username": c.username,
|
|
75
102
|
"access_token": "***",
|
|
@@ -89,13 +116,14 @@ def cmd_config(args: argparse.Namespace) -> None:
|
|
|
89
116
|
"current-server": cmd_config_current_server,
|
|
90
117
|
"get-servers": cmd_config_get_servers,
|
|
91
118
|
"use-server": cmd_config_use_server,
|
|
119
|
+
"rename-server": cmd_config_rename_server,
|
|
92
120
|
"view": cmd_config_view,
|
|
93
121
|
}
|
|
94
122
|
handler = handlers.get(sub) if sub else None
|
|
95
123
|
if handler is None:
|
|
96
124
|
print(
|
|
97
125
|
"error: provide a config subcommand "
|
|
98
|
-
"(current-server, get-servers, use-server, view)",
|
|
126
|
+
"(current-server, get-servers, use-server, rename-server, view)",
|
|
99
127
|
file=sys.stderr,
|
|
100
128
|
)
|
|
101
129
|
sys.exit(1)
|
|
@@ -248,6 +248,7 @@ def _cmd_download(client: EmbyClient, args: argparse.Namespace) -> None:
|
|
|
248
248
|
raw_type=_text(args, "item_type"),
|
|
249
249
|
mirror_path=opts.mirror_path,
|
|
250
250
|
path_strip=opts.path_strip,
|
|
251
|
+
pick_best=pick_best,
|
|
251
252
|
)
|
|
252
253
|
print_done(stats)
|
|
253
254
|
raise SystemExit(stats.exit_code())
|
|
@@ -18,9 +18,10 @@ import requests
|
|
|
18
18
|
|
|
19
19
|
from emby_cli.client import EmbyClient
|
|
20
20
|
from emby_cli.constants import SEARCH_ITEM_TYPES, SHOW_ITEM_FIELDS
|
|
21
|
-
from emby_cli.media_sort import sort_media_items
|
|
21
|
+
from emby_cli.media_sort import sort_key_id, sort_media_items
|
|
22
22
|
from emby_cli.output import Stats, print_done, print_download, print_error, print_skip
|
|
23
23
|
from emby_cli.resolve import (
|
|
24
|
+
_pick_episode_versions,
|
|
24
25
|
classify_resolution,
|
|
25
26
|
item_label,
|
|
26
27
|
item_video_width,
|
|
@@ -454,6 +455,59 @@ class DownloadOpts:
|
|
|
454
455
|
)
|
|
455
456
|
|
|
456
457
|
|
|
458
|
+
def sort_episodes(episodes: list[dict]) -> list[dict]:
|
|
459
|
+
"""Sort episodes by season, episode number, then Emby Id."""
|
|
460
|
+
|
|
461
|
+
def key(ep: dict) -> tuple:
|
|
462
|
+
season = ep.get("ParentIndexNumber")
|
|
463
|
+
number = ep.get("IndexNumber")
|
|
464
|
+
if season is not None and number is not None:
|
|
465
|
+
return (0, int(season), int(number), sort_key_id(ep))
|
|
466
|
+
if season is not None:
|
|
467
|
+
return (1, int(season), 0, sort_key_id(ep))
|
|
468
|
+
if number is not None:
|
|
469
|
+
return (2, 0, int(number), sort_key_id(ep))
|
|
470
|
+
return (3, 0, 0, sort_key_id(ep))
|
|
471
|
+
|
|
472
|
+
return sorted(episodes, key=key)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def expand_item_for_download(
|
|
476
|
+
client: EmbyClient,
|
|
477
|
+
item: dict,
|
|
478
|
+
*,
|
|
479
|
+
pick_best: bool = False,
|
|
480
|
+
) -> list[dict] | None:
|
|
481
|
+
"""Return download targets for *item* (one row, or all episodes for Series)."""
|
|
482
|
+
if item.get("Type") != "Series":
|
|
483
|
+
return [item]
|
|
484
|
+
|
|
485
|
+
name = item.get("Name") or item.get("Id")
|
|
486
|
+
try:
|
|
487
|
+
episodes = client.get_show_episodes(item["Id"], season=None)
|
|
488
|
+
except (requests.RequestException, RuntimeError) as exc:
|
|
489
|
+
print_error(f"listing episodes for series '{name}': {exc}")
|
|
490
|
+
return None
|
|
491
|
+
|
|
492
|
+
if not episodes:
|
|
493
|
+
print(f"Series '{name}': no episodes found.")
|
|
494
|
+
return []
|
|
495
|
+
|
|
496
|
+
series_name = item.get("Name")
|
|
497
|
+
enriched: list[dict] = []
|
|
498
|
+
for ep in episodes:
|
|
499
|
+
row = dict(ep)
|
|
500
|
+
if series_name and not row.get("SeriesName"):
|
|
501
|
+
row["SeriesName"] = series_name
|
|
502
|
+
enriched.append(row)
|
|
503
|
+
|
|
504
|
+
picked = _pick_episode_versions(sort_episodes(enriched), pick_best=pick_best)
|
|
505
|
+
if picked is None:
|
|
506
|
+
return None
|
|
507
|
+
print(f"Series '{name}': {len(picked)} episode(s)")
|
|
508
|
+
return picked
|
|
509
|
+
|
|
510
|
+
|
|
457
511
|
def resolve_rate(item: dict, throttle: float) -> float | None:
|
|
458
512
|
if not throttle:
|
|
459
513
|
return None
|
|
@@ -584,6 +638,7 @@ def download_item_ids(
|
|
|
584
638
|
raw_type: str | None = None,
|
|
585
639
|
mirror_path: bool = False,
|
|
586
640
|
path_strip: str | None = None,
|
|
641
|
+
pick_best: bool = False,
|
|
587
642
|
) -> Stats:
|
|
588
643
|
"""Download comma-separated item IDs."""
|
|
589
644
|
item_ids = [part.strip() for part in item_id.split(",") if part.strip()]
|
|
@@ -605,7 +660,14 @@ def download_item_ids(
|
|
|
605
660
|
)
|
|
606
661
|
stats.error += 1
|
|
607
662
|
continue
|
|
608
|
-
|
|
663
|
+
targets = expand_item_for_download(client, item, pick_best=pick_best)
|
|
664
|
+
if targets is None:
|
|
665
|
+
stats.error += 1
|
|
666
|
+
continue
|
|
667
|
+
if not targets and item.get("Type") == "Series":
|
|
668
|
+
stats.error += 1
|
|
669
|
+
continue
|
|
670
|
+
items.extend(targets)
|
|
609
671
|
if items:
|
|
610
672
|
got = download_items(
|
|
611
673
|
client,
|
|
@@ -673,9 +735,21 @@ def download_from_file(
|
|
|
673
735
|
totals.error += 1
|
|
674
736
|
continue
|
|
675
737
|
|
|
676
|
-
|
|
738
|
+
targets = expand_item_for_download(client, item, pick_best=pick_best)
|
|
739
|
+
if targets is None:
|
|
740
|
+
elapsed = time.monotonic() - line_t0
|
|
741
|
+
line_stats.append((label, "ERROR", elapsed, "ambiguous episode versions"))
|
|
742
|
+
totals.error += 1
|
|
743
|
+
continue
|
|
744
|
+
if not targets:
|
|
745
|
+
elapsed = time.monotonic() - line_t0
|
|
746
|
+
line_stats.append((label, "ERROR", elapsed, "no episodes"))
|
|
747
|
+
totals.error += 1
|
|
748
|
+
continue
|
|
749
|
+
|
|
750
|
+
got = download_items(
|
|
677
751
|
client,
|
|
678
|
-
|
|
752
|
+
targets,
|
|
679
753
|
output,
|
|
680
754
|
method=method,
|
|
681
755
|
force=force,
|
|
@@ -685,20 +759,20 @@ def download_from_file(
|
|
|
685
759
|
path_strip=path_strip,
|
|
686
760
|
)
|
|
687
761
|
elapsed = time.monotonic() - line_t0
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
"
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
762
|
+
if got.error:
|
|
763
|
+
status = "ERROR"
|
|
764
|
+
elif dry_run and got.ok:
|
|
765
|
+
status = "DRY RUN"
|
|
766
|
+
elif got.skip and not got.ok:
|
|
767
|
+
status = "SKIPPED"
|
|
768
|
+
elif got.skip:
|
|
769
|
+
status = "PARTIAL"
|
|
770
|
+
else:
|
|
771
|
+
status = "OK"
|
|
695
772
|
line_stats.append((label, status, elapsed, item.get("Name", "")))
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
totals.skip += 1
|
|
700
|
-
elif result == "error":
|
|
701
|
-
totals.error += 1
|
|
773
|
+
totals.ok += got.ok
|
|
774
|
+
totals.skip += got.skip
|
|
775
|
+
totals.error += got.error
|
|
702
776
|
continue
|
|
703
777
|
|
|
704
778
|
label = raw_line
|
|
@@ -9,15 +9,18 @@ from emby_cli.auth_cache import (
|
|
|
9
9
|
AuthCacheEntry,
|
|
10
10
|
auth_store_path,
|
|
11
11
|
clear_auth_cache,
|
|
12
|
+
display_name,
|
|
12
13
|
get_active_entry,
|
|
13
14
|
list_auth_cache_entries,
|
|
14
15
|
list_contexts,
|
|
15
16
|
load_auth_cache,
|
|
16
17
|
load_store,
|
|
18
|
+
rename_context_alias,
|
|
17
19
|
save_auth_cache,
|
|
18
20
|
set_current_context,
|
|
19
21
|
unique_cached_server_urls,
|
|
20
22
|
upsert_context,
|
|
23
|
+
validate_alias,
|
|
21
24
|
)
|
|
22
25
|
|
|
23
26
|
|
|
@@ -79,6 +82,46 @@ def test_set_current_context_by_unique_server(tmp_path, monkeypatch):
|
|
|
79
82
|
assert get_active_entry().username == "alice"
|
|
80
83
|
|
|
81
84
|
|
|
85
|
+
def test_rename_context_alias_and_use_by_alias(tmp_path, monkeypatch):
|
|
86
|
+
monkeypatch.setenv("EMBY_CACHE_DIR", str(tmp_path))
|
|
87
|
+
save_auth_cache(
|
|
88
|
+
AuthCacheEntry.create("http://a:8096", "alice", "tok-a", "uid-a")
|
|
89
|
+
)
|
|
90
|
+
save_auth_cache(
|
|
91
|
+
AuthCacheEntry.create("http://b:8096", "bob", "tok-b", "uid-b")
|
|
92
|
+
)
|
|
93
|
+
entry = rename_context_alias("alice@http://a:8096", "home")
|
|
94
|
+
assert display_name(entry) == "home"
|
|
95
|
+
assert entry.name == "alice@http://a:8096"
|
|
96
|
+
|
|
97
|
+
set_current_context("home")
|
|
98
|
+
assert get_active_entry().name == "alice@http://a:8096"
|
|
99
|
+
|
|
100
|
+
store = load_store()
|
|
101
|
+
assert store.contexts[0].alias == "home" or store.contexts[1].alias == "home"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_upsert_preserves_alias(tmp_path, monkeypatch):
|
|
105
|
+
monkeypatch.setenv("EMBY_CACHE_DIR", str(tmp_path))
|
|
106
|
+
save_auth_cache(
|
|
107
|
+
AuthCacheEntry.create("http://a:8096", "alice", "tok-a", "uid-a")
|
|
108
|
+
)
|
|
109
|
+
rename_context_alias("alice@http://a:8096", "home")
|
|
110
|
+
upsert_context(
|
|
111
|
+
AuthCacheEntry.create("http://a:8096", "alice", "tok-new", "uid-a"),
|
|
112
|
+
activate=True,
|
|
113
|
+
)
|
|
114
|
+
ctx = next(c for c in list_contexts() if c.username == "alice")
|
|
115
|
+
assert ctx.alias == "home"
|
|
116
|
+
assert ctx.access_token == "tok-new"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_validate_alias_rejects_whitespace_and_at(tmp_path, monkeypatch):
|
|
120
|
+
monkeypatch.setenv("EMBY_CACHE_DIR", str(tmp_path))
|
|
121
|
+
assert "whitespace" in validate_alias("my home")
|
|
122
|
+
assert "@" in validate_alias("user@host")
|
|
123
|
+
|
|
124
|
+
|
|
82
125
|
def test_load_prefers_active_for_server(tmp_path, monkeypatch):
|
|
83
126
|
monkeypatch.setenv("EMBY_CACHE_DIR", str(tmp_path))
|
|
84
127
|
older = AuthCacheEntry.create("http://host:8096", "alice", "tok-a", "uid-a")
|
|
@@ -10,6 +10,7 @@ from emby_cli.auth_cache import AuthCacheEntry, save_auth_cache, upsert_context
|
|
|
10
10
|
from emby_cli.commands.config import (
|
|
11
11
|
cmd_config_current_server,
|
|
12
12
|
cmd_config_get_servers,
|
|
13
|
+
cmd_config_rename_server,
|
|
13
14
|
cmd_config_use_server,
|
|
14
15
|
cmd_config_view,
|
|
15
16
|
)
|
|
@@ -61,6 +62,75 @@ def test_config_use_server(tmp_path, monkeypatch, capsys):
|
|
|
61
62
|
assert capsys.readouterr().out.strip() == "alice@http://a:8096"
|
|
62
63
|
|
|
63
64
|
|
|
65
|
+
def test_config_rename_and_use_alias(tmp_path, monkeypatch, capsys):
|
|
66
|
+
monkeypatch.setenv("EMBY_CACHE_DIR", str(tmp_path))
|
|
67
|
+
save_auth_cache(
|
|
68
|
+
AuthCacheEntry.create(
|
|
69
|
+
"http://emby.villageek.gmbros.net",
|
|
70
|
+
"media",
|
|
71
|
+
"tok-a",
|
|
72
|
+
"uid-a",
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
save_auth_cache(
|
|
76
|
+
AuthCacheEntry.create(
|
|
77
|
+
"http://sniperembyp7p8.zapto.org:8096",
|
|
78
|
+
"Rbembypr022",
|
|
79
|
+
"tok-b",
|
|
80
|
+
"uid-b",
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
cmd_config_rename_server(
|
|
84
|
+
argparse.Namespace(
|
|
85
|
+
server_name="media@http://emby.villageek.gmbros.net",
|
|
86
|
+
new_name="synology",
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
assert (
|
|
90
|
+
'Renamed server "media@http://emby.villageek.gmbros.net" to "synology"'
|
|
91
|
+
in capsys.readouterr().out
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
cmd_config_get_servers(argparse.Namespace())
|
|
95
|
+
out = capsys.readouterr().out
|
|
96
|
+
assert "synology" in out
|
|
97
|
+
assert "media@http://emby.villageek.gmbros.net" not in out.split("SERVER")[0]
|
|
98
|
+
|
|
99
|
+
cmd_config_use_server(argparse.Namespace(server_name="synology"))
|
|
100
|
+
assert (
|
|
101
|
+
'Switched to server "synology" (media@http://emby.villageek.gmbros.net)'
|
|
102
|
+
in capsys.readouterr().out
|
|
103
|
+
)
|
|
104
|
+
cmd_config_current_server(argparse.Namespace())
|
|
105
|
+
assert capsys.readouterr().out.strip() == "synology"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_config_rename_server_duplicate_alias(tmp_path, monkeypatch, capsys):
|
|
109
|
+
monkeypatch.setenv("EMBY_CACHE_DIR", str(tmp_path))
|
|
110
|
+
save_auth_cache(
|
|
111
|
+
AuthCacheEntry.create("http://a:8096", "alice", "tok-a", "uid-a")
|
|
112
|
+
)
|
|
113
|
+
save_auth_cache(
|
|
114
|
+
AuthCacheEntry.create("http://b:8096", "bob", "tok-b", "uid-b")
|
|
115
|
+
)
|
|
116
|
+
cmd_config_rename_server(
|
|
117
|
+
argparse.Namespace(
|
|
118
|
+
server_name="alice@http://a:8096",
|
|
119
|
+
new_name="home",
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
capsys.readouterr()
|
|
123
|
+
with pytest.raises(SystemExit) as exc:
|
|
124
|
+
cmd_config_rename_server(
|
|
125
|
+
argparse.Namespace(
|
|
126
|
+
server_name="bob@http://b:8096",
|
|
127
|
+
new_name="home",
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
assert exc.value.code == 1
|
|
131
|
+
assert "already used" in capsys.readouterr().err
|
|
132
|
+
|
|
133
|
+
|
|
64
134
|
def test_config_view_redacts_token(tmp_path, monkeypatch, capsys):
|
|
65
135
|
monkeypatch.setenv("EMBY_CACHE_DIR", str(tmp_path))
|
|
66
136
|
save_auth_cache(
|
|
@@ -14,8 +14,10 @@ from emby_cli.item_ops import (
|
|
|
14
14
|
ItemListingQuery,
|
|
15
15
|
ItemResolutionError,
|
|
16
16
|
build_item_listing_query,
|
|
17
|
+
download_item_ids,
|
|
17
18
|
emby_search_term_for_strict_query,
|
|
18
19
|
episodes_for_title_line,
|
|
20
|
+
expand_item_for_download,
|
|
19
21
|
fetch_item_listing,
|
|
20
22
|
item_types_for_api,
|
|
21
23
|
matches_strict_name_query,
|
|
@@ -24,8 +26,10 @@ from emby_cli.item_ops import (
|
|
|
24
26
|
play_url,
|
|
25
27
|
playable_items_for_parent,
|
|
26
28
|
resolve_item,
|
|
29
|
+
sort_episodes,
|
|
27
30
|
split_listing_search_query,
|
|
28
31
|
)
|
|
32
|
+
from emby_cli.output import Stats
|
|
29
33
|
|
|
30
34
|
ITEMS = [
|
|
31
35
|
{"Id": "100", "Name": "The Matrix", "Type": "Movie", "ProductionYear": 1999},
|
|
@@ -339,3 +343,60 @@ def test_resolve_item_by_id_prefix_uses_catalog():
|
|
|
339
343
|
assert resolve_item(client, item_id="20", use_cache=False) == ITEMS[1]
|
|
340
344
|
get.assert_called_once_with("20", fields=SHOW_ITEM_FIELDS, use_cache=False)
|
|
341
345
|
search.assert_called_once_with("", item_types="Movie,Episode,Audio,Video", use_cache=False)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def test_sort_episodes_orders_by_season_and_number():
|
|
349
|
+
episodes = [
|
|
350
|
+
{"Id": "3", "ParentIndexNumber": 2, "IndexNumber": 1},
|
|
351
|
+
{"Id": "1", "ParentIndexNumber": 1, "IndexNumber": 2},
|
|
352
|
+
{"Id": "2", "ParentIndexNumber": 1, "IndexNumber": 1},
|
|
353
|
+
]
|
|
354
|
+
ordered = sort_episodes(episodes)
|
|
355
|
+
assert [ep["Id"] for ep in ordered] == ["2", "1", "3"]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def test_expand_item_for_download_series(capsys):
|
|
359
|
+
client = MagicMock()
|
|
360
|
+
series = {"Id": "s1", "Name": "Show", "Type": "Series"}
|
|
361
|
+
client.get_show_episodes.return_value = [
|
|
362
|
+
{"Id": "e2", "Type": "Episode", "ParentIndexNumber": 1, "IndexNumber": 2, "Name": "B"},
|
|
363
|
+
{"Id": "e1", "Type": "Episode", "ParentIndexNumber": 1, "IndexNumber": 1, "Name": "A"},
|
|
364
|
+
]
|
|
365
|
+
targets = expand_item_for_download(client, series)
|
|
366
|
+
assert targets is not None
|
|
367
|
+
assert [ep["Id"] for ep in targets] == ["e1", "e2"]
|
|
368
|
+
assert targets[0]["SeriesName"] == "Show"
|
|
369
|
+
assert "2 episode(s)" in capsys.readouterr().out
|
|
370
|
+
client.get_show_episodes.assert_called_once_with("s1", season=None)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def test_expand_item_for_download_movie_passthrough():
|
|
374
|
+
client = MagicMock()
|
|
375
|
+
movie = {"Id": "m1", "Name": "Film", "Type": "Movie"}
|
|
376
|
+
assert expand_item_for_download(client, movie) == [movie]
|
|
377
|
+
client.get_show_episodes.assert_not_called()
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def test_download_item_ids_expands_series(tmp_path, capsys):
|
|
381
|
+
client = MagicMock()
|
|
382
|
+
series = {"Id": "s1", "Name": "Show", "Type": "Series"}
|
|
383
|
+
episodes = [
|
|
384
|
+
{"Id": "e1", "Type": "Episode", "ParentIndexNumber": 1, "IndexNumber": 1, "Name": "A"},
|
|
385
|
+
]
|
|
386
|
+
client.get_item_info.return_value = series
|
|
387
|
+
client.get_show_episodes.return_value = episodes
|
|
388
|
+
with patch("emby_cli.item_ops.download_items") as download_items:
|
|
389
|
+
download_items.return_value = Stats(ok=1)
|
|
390
|
+
stats = download_item_ids(
|
|
391
|
+
client,
|
|
392
|
+
"s1",
|
|
393
|
+
tmp_path,
|
|
394
|
+
method="download",
|
|
395
|
+
force=False,
|
|
396
|
+
throttle=0,
|
|
397
|
+
dry_run=True,
|
|
398
|
+
)
|
|
399
|
+
assert stats.ok == 1
|
|
400
|
+
passed_items = download_items.call_args.args[1]
|
|
401
|
+
assert len(passed_items) == 1
|
|
402
|
+
assert passed_items[0]["Id"] == "e1"
|
|
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
|
|
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
|