emby-cli 0.7.1__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.1 → emby_cli-0.7.2}/AGENTS.md +4 -2
  2. {emby_cli-0.7.1 → emby_cli-0.7.2}/CHANGELOG.md +9 -0
  3. {emby_cli-0.7.1 → emby_cli-0.7.2}/PKG-INFO +2 -1
  4. {emby_cli-0.7.1 → emby_cli-0.7.2}/README.md +1 -0
  5. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/_version.py +2 -2
  6. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/cli.py +4 -1
  7. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/commands/item.py +1 -0
  8. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/item_ops.py +91 -17
  9. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_item_ops.py +61 -0
  10. {emby_cli-0.7.1 → emby_cli-0.7.2}/.env.example +0 -0
  11. {emby_cli-0.7.1 → emby_cli-0.7.2}/.github/workflows/ci.yml +0 -0
  12. {emby_cli-0.7.1 → emby_cli-0.7.2}/.github/workflows/publish.yml +0 -0
  13. {emby_cli-0.7.1 → emby_cli-0.7.2}/.gitignore +0 -0
  14. {emby_cli-0.7.1 → emby_cli-0.7.2}/LICENSE +0 -0
  15. {emby_cli-0.7.1 → emby_cli-0.7.2}/pyproject.toml +0 -0
  16. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/__init__.py +0 -0
  17. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/__main__.py +0 -0
  18. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/api/__init__.py +0 -0
  19. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/api/collections.py +0 -0
  20. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/api/items.py +0 -0
  21. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/api/libraries.py +0 -0
  22. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/auth_cache.py +0 -0
  23. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/client.py +0 -0
  24. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/collection_ops.py +0 -0
  25. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/commands/__init__.py +0 -0
  26. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/commands/collection.py +0 -0
  27. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/commands/config.py +0 -0
  28. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/commands/help.py +0 -0
  29. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/commands/info.py +0 -0
  30. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/commands/library.py +0 -0
  31. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/commands/login.py +0 -0
  32. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/commands/logout.py +0 -0
  33. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/commands/version.py +0 -0
  34. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/constants.py +0 -0
  35. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/credentials.py +0 -0
  36. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/data_cache.py +0 -0
  37. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/detail_output.py +0 -0
  38. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/download_ops.py +0 -0
  39. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/library_ops.py +0 -0
  40. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/media_sort.py +0 -0
  41. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/output.py +0 -0
  42. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/resolve.py +0 -0
  43. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/types.py +0 -0
  44. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/util.py +0 -0
  45. {emby_cli-0.7.1 → emby_cli-0.7.2}/src/emby_cli/version.py +0 -0
  46. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_auth_cache.py +0 -0
  47. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_auth_expired.py +0 -0
  48. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_client.py +0 -0
  49. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_client_collection.py +0 -0
  50. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_client_item.py +0 -0
  51. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_client_library.py +0 -0
  52. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_client_retry.py +0 -0
  53. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_collection.py +0 -0
  54. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_collection_ops.py +0 -0
  55. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_config.py +0 -0
  56. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_credentials.py +0 -0
  57. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_data_cache.py +0 -0
  58. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_download_one.py +0 -0
  59. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_download_ops.py +0 -0
  60. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_info.py +0 -0
  61. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_item.py +0 -0
  62. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_item_performance.py +0 -0
  63. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_library.py +0 -0
  64. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_library_ops.py +0 -0
  65. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_logout.py +0 -0
  66. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_media_sort.py +0 -0
  67. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_output.py +0 -0
  68. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_parser.py +0 -0
  69. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_resolve.py +0 -0
  70. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_resolve_table.py +0 -0
  71. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_resolve_titles.py +0 -0
  72. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_session_auth.py +0 -0
  73. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_urllib3_warning.py +0 -0
  74. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_util.py +0 -0
  75. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_validate_args.py +0 -0
  76. {emby_cli-0.7.1 → emby_cli-0.7.2}/tests/test_version.py +0 -0
@@ -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,15 @@
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
+
5
14
  ## 0.7.1
6
15
 
7
16
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: emby-cli
3
- Version: 0.7.1
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
  ```
@@ -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
  ```
@@ -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.1'
22
- __version_tuple__ = version_tuple = (0, 7, 1)
21
+ __version__ = version = '0.7.2'
22
+ __version_tuple__ = version_tuple = (0, 7, 2)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -540,7 +540,10 @@ def build_parser() -> argparse.ArgumentParser:
540
540
 
541
541
  it_download = it_sub.add_parser("download", help="Download media items")
542
542
  it_download.add_argument("query", nargs="?", metavar="QUERY")
543
- 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
+ )
544
547
  it_download.add_argument(
545
548
  "--type",
546
549
  dest="item_type",
@@ -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
@@ -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