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.
Files changed (76) hide show
  1. {emby_cli-0.7.0 → emby_cli-0.7.2}/AGENTS.md +5 -3
  2. {emby_cli-0.7.0 → emby_cli-0.7.2}/CHANGELOG.md +17 -0
  3. {emby_cli-0.7.0 → emby_cli-0.7.2}/PKG-INFO +4 -1
  4. {emby_cli-0.7.0 → emby_cli-0.7.2}/README.md +3 -0
  5. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/_version.py +2 -2
  6. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/auth_cache.py +72 -6
  7. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/cli.py +18 -1
  8. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/commands/config.py +32 -4
  9. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/commands/item.py +1 -0
  10. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/item_ops.py +91 -17
  11. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_auth_cache.py +43 -0
  12. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_config.py +70 -0
  13. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_item_ops.py +61 -0
  14. {emby_cli-0.7.0 → emby_cli-0.7.2}/.env.example +0 -0
  15. {emby_cli-0.7.0 → emby_cli-0.7.2}/.github/workflows/ci.yml +0 -0
  16. {emby_cli-0.7.0 → emby_cli-0.7.2}/.github/workflows/publish.yml +0 -0
  17. {emby_cli-0.7.0 → emby_cli-0.7.2}/.gitignore +0 -0
  18. {emby_cli-0.7.0 → emby_cli-0.7.2}/LICENSE +0 -0
  19. {emby_cli-0.7.0 → emby_cli-0.7.2}/pyproject.toml +0 -0
  20. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/__init__.py +0 -0
  21. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/__main__.py +0 -0
  22. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/api/__init__.py +0 -0
  23. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/api/collections.py +0 -0
  24. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/api/items.py +0 -0
  25. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/api/libraries.py +0 -0
  26. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/client.py +0 -0
  27. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/collection_ops.py +0 -0
  28. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/commands/__init__.py +0 -0
  29. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/commands/collection.py +0 -0
  30. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/commands/help.py +0 -0
  31. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/commands/info.py +0 -0
  32. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/commands/library.py +0 -0
  33. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/commands/login.py +0 -0
  34. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/commands/logout.py +0 -0
  35. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/commands/version.py +0 -0
  36. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/constants.py +0 -0
  37. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/credentials.py +0 -0
  38. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/data_cache.py +0 -0
  39. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/detail_output.py +0 -0
  40. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/download_ops.py +0 -0
  41. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/library_ops.py +0 -0
  42. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/media_sort.py +0 -0
  43. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/output.py +0 -0
  44. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/resolve.py +0 -0
  45. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/types.py +0 -0
  46. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/util.py +0 -0
  47. {emby_cli-0.7.0 → emby_cli-0.7.2}/src/emby_cli/version.py +0 -0
  48. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_auth_expired.py +0 -0
  49. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_client.py +0 -0
  50. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_client_collection.py +0 -0
  51. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_client_item.py +0 -0
  52. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_client_library.py +0 -0
  53. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_client_retry.py +0 -0
  54. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_collection.py +0 -0
  55. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_collection_ops.py +0 -0
  56. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_credentials.py +0 -0
  57. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_data_cache.py +0 -0
  58. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_download_one.py +0 -0
  59. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_download_ops.py +0 -0
  60. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_info.py +0 -0
  61. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_item.py +0 -0
  62. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_item_performance.py +0 -0
  63. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_library.py +0 -0
  64. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_library_ops.py +0 -0
  65. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_logout.py +0 -0
  66. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_media_sort.py +0 -0
  67. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_output.py +0 -0
  68. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_parser.py +0 -0
  69. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_resolve.py +0 -0
  70. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_resolve_table.py +0 -0
  71. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_resolve_titles.py +0 -0
  72. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_session_auth.py +0 -0
  73. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_urllib3_warning.py +0 -0
  74. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_util.py +0 -0
  75. {emby_cli-0.7.0 → emby_cli-0.7.2}/tests/test_validate_args.py +0 -0
  76. {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) and downloads
356
- via `item_ops` (`download_items`, `download_item_ids`, `download_from_file`).
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.0
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.0'
22
- __version_tuple__ = version_tuple = (0, 7, 0)
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=entry.name or context_name(entry.server_url, entry.username),
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 set_current_context(name: str) -> AuthCacheEntry:
236
- """Set ``current_context`` by exact name or unique server URL match."""
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
- # Unique match by server_url or username@server fragment.
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("/") or c.name.endswith(f"@{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
- entry = matches[0]
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("--id", help="Media item ID to download (comma-separated for multiple)")
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.name)
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.name, ctx.server_url, ctx.username))
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
- print(f"Switched to server {entry.name}")
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
- items.append(item)
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
- result = download_one_item(
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
- item,
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
- status_map = {
689
- "ok": "OK",
690
- "skip": "SKIPPED",
691
- "error": "ERROR",
692
- "dry_run": "DRY RUN",
693
- }
694
- status = status_map.get(result, "ERROR")
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
- if result == "ok":
697
- totals.ok += 1
698
- elif result == "skip":
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