emby-cli 0.1.0__tar.gz → 0.2.0__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 (39) hide show
  1. emby_cli-0.2.0/CHANGELOG.md +11 -0
  2. {emby_cli-0.1.0 → emby_cli-0.2.0}/PKG-INFO +7 -16
  3. {emby_cli-0.1.0 → emby_cli-0.2.0}/README.md +6 -15
  4. {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/_version.py +2 -2
  5. {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/cli.py +21 -5
  6. {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/client.py +170 -59
  7. emby_cli-0.2.0/src/emby_cli/commands/batch.py +158 -0
  8. emby_cli-0.2.0/src/emby_cli/commands/download.py +71 -0
  9. {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/commands/list.py +6 -9
  10. {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/commands/play.py +4 -3
  11. emby_cli-0.2.0/src/emby_cli/commands/search.py +30 -0
  12. emby_cli-0.2.0/src/emby_cli/commands/sync.py +46 -0
  13. {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/constants.py +2 -0
  14. emby_cli-0.2.0/src/emby_cli/download_ops.py +136 -0
  15. emby_cli-0.2.0/src/emby_cli/output.py +79 -0
  16. {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/resolve.py +82 -33
  17. emby_cli-0.2.0/tests/test_client.py +102 -0
  18. emby_cli-0.2.0/tests/test_output.py +19 -0
  19. emby_cli-0.2.0/tests/test_parser.py +67 -0
  20. emby_cli-0.1.0/CHANGELOG.md +0 -5
  21. emby_cli-0.1.0/src/emby_cli/commands/batch.py +0 -244
  22. emby_cli-0.1.0/src/emby_cli/commands/download.py +0 -77
  23. emby_cli-0.1.0/src/emby_cli/commands/search.py +0 -31
  24. emby_cli-0.1.0/src/emby_cli/commands/sync.py +0 -54
  25. emby_cli-0.1.0/src/emby_cli/download_ops.py +0 -47
  26. emby_cli-0.1.0/tests/test_parser.py +0 -32
  27. {emby_cli-0.1.0 → emby_cli-0.2.0}/.env.example +0 -0
  28. {emby_cli-0.1.0 → emby_cli-0.2.0}/.github/workflows/ci.yml +0 -0
  29. {emby_cli-0.1.0 → emby_cli-0.2.0}/.github/workflows/publish.yml +0 -0
  30. {emby_cli-0.1.0 → emby_cli-0.2.0}/.gitignore +0 -0
  31. {emby_cli-0.1.0 → emby_cli-0.2.0}/LICENSE +0 -0
  32. {emby_cli-0.1.0 → emby_cli-0.2.0}/pyproject.toml +0 -0
  33. {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/__init__.py +0 -0
  34. {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/__main__.py +0 -0
  35. {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/commands/__init__.py +0 -0
  36. {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/util.py +0 -0
  37. {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/version.py +0 -0
  38. {emby_cli-0.1.0 → emby_cli-0.2.0}/tests/test_resolve.py +0 -0
  39. {emby_cli-0.1.0 → emby_cli-0.2.0}/tests/test_util.py +0 -0
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ## 0.2.0
6
+
7
+ - API fixes: `get_item_info` Fields, `/Users/Me`, safer 416 resume, `api_key` on play URLs, client version from package, `/emby` base URL normalize, HLS without invalid `copy` codecs, paginated episodes.
8
+ - Shared resolve for `play`/`batch` (strict by default; `--pick-best-item 0|1`).
9
+ - Homogeneous CLI messages (`output.py`); shared library/download loops; non-zero exit on errors / batch not-found.
10
+ - `search`: paginate Emby results (was capped at 25); `--count` / `-n` to limit how many to return (default: all).
11
+ - More tests (`test_client`, `test_output`, parser coverage).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: emby-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
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
@@ -52,13 +52,6 @@ python3 -m venv .venv && source .venv/bin/activate
52
52
  pip install -e ".[dev]"
53
53
  ```
54
54
 
55
- In the gmbros.net ops tree (`emby-downloader/`), use the root Makefile instead:
56
-
57
- ```bash
58
- cd ~/.local/gmbros.net/emby-downloader
59
- make install
60
- ```
61
-
62
55
  ## Configuration
63
56
 
64
57
  Set environment variables (or pass CLI flags):
@@ -79,12 +72,17 @@ See `.env.example`. The app does not load `.env` itself — `source` it or expor
79
72
  ```bash
80
73
  emby-cli list
81
74
  emby-cli search "Title"
75
+ emby-cli search "Title" --count 50
82
76
  emby-cli download -i <itemId> -o ./downloads
83
77
  emby-cli sync -l "Movies"
84
78
  emby-cli batch -F titles.txt -n
79
+ emby-cli batch -F titles.txt --pick-best-item 1
85
80
  emby-cli play "Movie (2010)"
81
+ emby-cli play "Show S01E01" --pick-best-item 1
86
82
  ```
87
83
 
84
+ Title resolution (`play` / `batch`) is **strict** by default (year mismatch or multiple matches fail that line). Pass `--pick-best-item 1` to auto-select the best ≤1080p version. In `batch`, a season line like `Show S01` downloads the whole season (with the same pick-best rule per episode version).
85
+
88
86
  Download methods (`-m` / `EMBY_METHOD`):
89
87
 
90
88
  - `download` — `GET /Items/{id}/Download`
@@ -99,14 +97,7 @@ pytest -q
99
97
  python -m build && twine check dist/*
100
98
  ```
101
99
 
102
- Ops Makefile (gmbros.net host only, not in this repo):
103
-
104
- ```bash
105
- cd ~/.local/gmbros.net/emby-downloader
106
- make install / make test / make build / make check
107
- make push
108
- make release VERSION=0.1.0 # tag + gh release → CI publishes to PyPI
109
- ```
100
+ To publish a release, tag `vX.Y.Z` and push it (or create a GitHub Release). CI builds the package and publishes to PyPI via Trusted Publisher.
110
101
 
111
102
  ## License
112
103
 
@@ -18,13 +18,6 @@ python3 -m venv .venv && source .venv/bin/activate
18
18
  pip install -e ".[dev]"
19
19
  ```
20
20
 
21
- In the gmbros.net ops tree (`emby-downloader/`), use the root Makefile instead:
22
-
23
- ```bash
24
- cd ~/.local/gmbros.net/emby-downloader
25
- make install
26
- ```
27
-
28
21
  ## Configuration
29
22
 
30
23
  Set environment variables (or pass CLI flags):
@@ -45,12 +38,17 @@ See `.env.example`. The app does not load `.env` itself — `source` it or expor
45
38
  ```bash
46
39
  emby-cli list
47
40
  emby-cli search "Title"
41
+ emby-cli search "Title" --count 50
48
42
  emby-cli download -i <itemId> -o ./downloads
49
43
  emby-cli sync -l "Movies"
50
44
  emby-cli batch -F titles.txt -n
45
+ emby-cli batch -F titles.txt --pick-best-item 1
51
46
  emby-cli play "Movie (2010)"
47
+ emby-cli play "Show S01E01" --pick-best-item 1
52
48
  ```
53
49
 
50
+ Title resolution (`play` / `batch`) is **strict** by default (year mismatch or multiple matches fail that line). Pass `--pick-best-item 1` to auto-select the best ≤1080p version. In `batch`, a season line like `Show S01` downloads the whole season (with the same pick-best rule per episode version).
51
+
54
52
  Download methods (`-m` / `EMBY_METHOD`):
55
53
 
56
54
  - `download` — `GET /Items/{id}/Download`
@@ -65,14 +63,7 @@ pytest -q
65
63
  python -m build && twine check dist/*
66
64
  ```
67
65
 
68
- Ops Makefile (gmbros.net host only, not in this repo):
69
-
70
- ```bash
71
- cd ~/.local/gmbros.net/emby-downloader
72
- make install / make test / make build / make check
73
- make push
74
- make release VERSION=0.1.0 # tag + gh release → CI publishes to PyPI
75
- ```
66
+ To publish a release, tag `vX.Y.Z` and push it (or create a GitHub Release). CI builds the package and publishes to PyPI via Trusted Publisher.
76
67
 
77
68
  ## License
78
69
 
@@ -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.1.0'
22
- __version_tuple__ = version_tuple = (0, 1, 0)
21
+ __version__ = version = '0.2.0'
22
+ __version_tuple__ = version_tuple = (0, 2, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -22,6 +22,12 @@ from emby_cli.commands.sync import cmd_sync
22
22
  from emby_cli.constants import DEFAULT_OUTPUT
23
23
 
24
24
 
25
+ _FORCE_HELP = (
26
+ "Re-download even if local file matches remote size "
27
+ "(or HLS .mkv + .done marker exists)"
28
+ )
29
+
30
+
25
31
  def build_parser() -> argparse.ArgumentParser:
26
32
  env = os.environ.get
27
33
 
@@ -44,7 +50,7 @@ def build_parser() -> argparse.ArgumentParser:
44
50
  dl.add_argument("--item-id", "-i", default=env("EMBY_ITEM_ID"), help="Specific item ID to download (env: EMBY_ITEM_ID)")
45
51
  dl.add_argument("--output", "-o", default=env("EMBY_OUTPUT", DEFAULT_OUTPUT),
46
52
  help=f"Output directory (env: EMBY_OUTPUT, default: {DEFAULT_OUTPUT})")
47
- dl.add_argument("--force", "-f", action="store_true", help="Re-download even if file exists with matching size")
53
+ dl.add_argument("--force", "-f", action="store_true", help=_FORCE_HELP)
48
54
  dl.add_argument("--throttle", "-t", type=float, nargs="?", const=1.0, default=0,
49
55
  help="Limit speed to playback rate. Optional multiplier: 1=realtime, 1.5=50%% faster (default: off)")
50
56
  dl.add_argument("--method", "-m", default=env("EMBY_METHOD", "download"),
@@ -54,6 +60,13 @@ def build_parser() -> argparse.ArgumentParser:
54
60
 
55
61
  sr = sub.add_parser("search", help="Search for items by name")
56
62
  sr.add_argument("query", help="Search query")
63
+ sr.add_argument(
64
+ "--count", "-n",
65
+ type=int,
66
+ default=None,
67
+ metavar="N",
68
+ help="Max results to return (default: all, paginated)",
69
+ )
57
70
 
58
71
  pl = sub.add_parser("play", help="Play an item via DirectStreamUrl in an external player")
59
72
  pl.add_argument("query", nargs="?",
@@ -66,14 +79,14 @@ def build_parser() -> argparse.ArgumentParser:
66
79
  pl.add_argument("--wait", action="store_true",
67
80
  help="Block until the player process exits (default: detach and return)")
68
81
  pl.add_argument("--pick-best-item", type=int, choices=[0, 1], default=0,
69
- help="On ambiguous search results: 0=list and require --item-id (default), "
70
- "1=auto-select best ≤1080p like batch")
82
+ help="On ambiguous search results: 0=list and fail (default), "
83
+ "1=auto-select best ≤1080p")
71
84
 
72
85
  sy = sub.add_parser("sync", help="Sync all libraries (or one with --library)")
73
86
  sy.add_argument("--library", "-l", default=env("EMBY_LIBRARY"), help="Specific library to sync (env: EMBY_LIBRARY)")
74
87
  sy.add_argument("--output", "-o", default=env("EMBY_OUTPUT", DEFAULT_OUTPUT),
75
88
  help=f"Output directory (env: EMBY_OUTPUT, default: {DEFAULT_OUTPUT})")
76
- sy.add_argument("--force", "-f", action="store_true", help="Re-download even if file exists with matching size")
89
+ sy.add_argument("--force", "-f", action="store_true", help=_FORCE_HELP)
77
90
  sy.add_argument("--throttle", "-t", type=float, nargs="?", const=1.0, default=0,
78
91
  help="Limit speed to playback rate. Optional multiplier: 1=realtime, 1.5=50%% faster (default: off)")
79
92
  sy.add_argument("--method", "-m", default=env("EMBY_METHOD", "download"),
@@ -86,13 +99,16 @@ def build_parser() -> argparse.ArgumentParser:
86
99
  ba.add_argument("--dry-run", "-n", action="store_true", help="Search and select only, do not download")
87
100
  ba.add_argument("--output", "-o", default=env("EMBY_OUTPUT", DEFAULT_OUTPUT),
88
101
  help=f"Output directory (env: EMBY_OUTPUT, default: {DEFAULT_OUTPUT})")
89
- ba.add_argument("--force", "-f", action="store_true", help="Re-download even if file exists with matching size")
102
+ ba.add_argument("--force", "-f", action="store_true", help=_FORCE_HELP)
90
103
  ba.add_argument("--throttle", "-t", type=float, nargs="?", const=1.0, default=0,
91
104
  help="Limit speed to playback rate. Optional multiplier: 1=realtime, 1.5=50%% faster (default: off)")
92
105
  ba.add_argument("--method", "-m", default=env("EMBY_METHOD", "download"),
93
106
  choices=["download", "stream", "hls"],
94
107
  help="Download method: 'download' (API Download), 'stream' (browser-like original.*), "
95
108
  "or 'hls' (stream chunks + remux) (env: EMBY_METHOD)")
109
+ ba.add_argument("--pick-best-item", type=int, choices=[0, 1], default=0,
110
+ help="On ambiguous search results: 0=fail the line (default), "
111
+ "1=auto-select best ≤1080p (also per episode version in a season)")
96
112
 
97
113
  return p
98
114
 
@@ -6,7 +6,7 @@ import hashlib
6
6
  import shutil
7
7
  import time
8
8
  from pathlib import Path
9
- from urllib.parse import urlencode
9
+ from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
10
10
 
11
11
  import m3u8
12
12
  import requests
@@ -16,39 +16,56 @@ from emby_cli.constants import (
16
16
  CLIENT_NAME,
17
17
  DEFAULT_CHUNK,
18
18
  DEVICE_NAME,
19
+ ITEM_FIELDS,
19
20
  MAX_RETRIES,
20
21
  RETRY_BACKOFF_BASE,
21
22
  )
22
23
  from emby_cli.util import remux_segments
24
+ from emby_cli.version import get_version
23
25
 
24
26
  _DEVICE_ID = hashlib.md5(CLIENT_NAME.encode()).hexdigest()
25
27
 
26
28
 
29
+ def _client_version() -> str:
30
+ ver = get_version()
31
+ return ver if ver and ver != "unknown" else "0.0.0"
32
+
33
+
27
34
  class EmbyClient:
28
35
  def __init__(self, server_url: str, api_key: str | None = None):
29
- self.server_url = server_url.rstrip("/")
36
+ base = server_url.rstrip("/")
37
+ if base.lower().endswith("/emby"):
38
+ base = base[: -len("/emby")]
39
+ self.server_url = base.rstrip("/")
30
40
  self.api_key = api_key
31
41
  self.user_id: str | None = None
32
42
  self.access_token: str | None = api_key
33
43
  self.session = requests.Session()
44
+ ver = _client_version()
34
45
  self.session.headers.update({
35
46
  "X-Emby-Client": CLIENT_NAME,
36
47
  "X-Emby-Device-Name": DEVICE_NAME,
37
48
  "X-Emby-Device-Id": _DEVICE_ID,
38
- "X-Emby-Client-Version": "1.0.0",
49
+ "X-Emby-Client-Version": ver,
39
50
  })
40
51
 
41
52
  # -- helpers -------------------------------------------------------------
42
53
 
43
54
  def _url(self, path: str) -> str:
44
- return f"{self.server_url}/emby{path}" if not path.startswith("http") else path
55
+ if path.startswith("http"):
56
+ return path
57
+ if not path.startswith("/"):
58
+ path = "/" + path
59
+ if path.lower().startswith("/emby/"):
60
+ return f"{self.server_url}{path}"
61
+ return f"{self.server_url}/emby{path}"
45
62
 
46
63
  def _auth_header(self) -> dict:
47
64
  parts = [
48
65
  f'MediaBrowser Client="{CLIENT_NAME}"',
49
66
  f'Device="{DEVICE_NAME}"',
50
67
  f'DeviceId="{_DEVICE_ID}"',
51
- 'Version="1.0.0"',
68
+ f'Version="{_client_version()}"',
52
69
  ]
53
70
  if self.access_token:
54
71
  parts.append(f'Token="{self.access_token}"')
@@ -57,6 +74,7 @@ class EmbyClient:
57
74
  def _request_with_retry(self, method: str, path: str, **kwargs) -> requests.Response:
58
75
  kwargs.setdefault("headers", self._auth_header())
59
76
  url = self._url(path)
77
+ resp = None
60
78
  for attempt in range(1, MAX_RETRIES + 1):
61
79
  try:
62
80
  resp = self.session.request(method, url, **kwargs)
@@ -68,8 +86,8 @@ class EmbyClient:
68
86
  wait = RETRY_BACKOFF_BASE * (2 ** (attempt - 1))
69
87
  print(f" Connection error (attempt {attempt}/{MAX_RETRIES}), retrying in {wait}s: {exc}")
70
88
  time.sleep(wait)
71
- except requests.HTTPError as exc:
72
- if resp.status_code >= 500 and attempt < MAX_RETRIES:
89
+ except requests.HTTPError:
90
+ if resp is not None and resp.status_code >= 500 and attempt < MAX_RETRIES:
73
91
  wait = RETRY_BACKOFF_BASE * (2 ** (attempt - 1))
74
92
  print(f" Server error {resp.status_code} (attempt {attempt}/{MAX_RETRIES}), retrying in {wait}s")
75
93
  time.sleep(wait)
@@ -83,18 +101,48 @@ class EmbyClient:
83
101
  def _post(self, path: str, payload: dict | None = None) -> requests.Response:
84
102
  return self._request_with_retry("POST", path, json=payload)
85
103
 
104
+ @staticmethod
105
+ def _ensure_api_key(url: str, token: str | None) -> str:
106
+ """Append api_key= to *url* when missing (for external players / HLS)."""
107
+ if not token:
108
+ return url
109
+ parsed = urlparse(url)
110
+ qs = parse_qs(parsed.query, keep_blank_values=True)
111
+ keys = {k.lower() for k in qs}
112
+ if "api_key" in keys or "apikey" in keys:
113
+ return url
114
+ q = dict(qs)
115
+ # flatten single-value lists for urlencode
116
+ flat = {k: v[0] if len(v) == 1 else v for k, v in q.items()}
117
+ flat["api_key"] = token
118
+ return urlunparse(parsed._replace(query=urlencode(flat, doseq=True)))
119
+
86
120
  # -- auth ----------------------------------------------------------------
87
121
 
88
122
  def authenticate(self, username: str, password: str) -> None:
89
123
  data = {"Username": username, "Pw": password}
90
124
  resp = self._post("/Users/AuthenticateByName", data)
91
125
  body = resp.json()
92
- self.access_token = body["AccessToken"]
93
- self.user_id = body["User"]["Id"]
126
+ try:
127
+ self.access_token = body["AccessToken"]
128
+ self.user_id = body["User"]["Id"]
129
+ except (KeyError, TypeError) as exc:
130
+ raise RuntimeError(
131
+ "Authentication response missing AccessToken or User.Id"
132
+ ) from exc
94
133
 
95
134
  def resolve_user_id(self) -> str:
96
135
  if self.user_id:
97
136
  return self.user_id
137
+ # Prefer current user bound to the token / API key
138
+ try:
139
+ me = self._get("/Users/Me").json()
140
+ uid = me.get("Id")
141
+ if uid:
142
+ self.user_id = uid
143
+ return self.user_id
144
+ except requests.HTTPError:
145
+ pass
98
146
  users = self._get("/Users").json()
99
147
  if not users:
100
148
  raise RuntimeError("No users found on server")
@@ -121,7 +169,7 @@ class EmbyClient:
121
169
  "StartIndex": start,
122
170
  "Limit": limit,
123
171
  "Recursive": str(recursive).lower(),
124
- "Fields": "Path,MediaSources,DateCreated,Size,RunTimeTicks",
172
+ "Fields": ITEM_FIELDS,
125
173
  "SortBy": "SortName",
126
174
  "SortOrder": "Ascending",
127
175
  }
@@ -152,31 +200,81 @@ class EmbyClient:
152
200
 
153
201
  def get_item_info(self, item_id: str) -> dict:
154
202
  uid = self.resolve_user_id()
155
- resp = self._get(f"/Users/{uid}/Items/{item_id}")
203
+ resp = self._get(
204
+ f"/Users/{uid}/Items/{item_id}",
205
+ params={"Fields": ITEM_FIELDS},
206
+ )
156
207
  return resp.json()
157
208
 
158
- def search_items(self, query: str, item_types: str = "Movie", limit: int = 25) -> list[dict]:
209
+ def search_items(
210
+ self,
211
+ query: str,
212
+ item_types: str = "Movie",
213
+ limit: int | None = 25,
214
+ ) -> list[dict]:
215
+ """Search items by name.
216
+
217
+ *limit* caps how many items to return. ``None`` fetches all pages
218
+ (Emby ``TotalRecordCount``). Internal callers (resolve) keep the
219
+ default of 25; the CLI ``search --count`` overrides this.
220
+ """
159
221
  uid = self.resolve_user_id()
160
- params = {
161
- "SearchTerm": query,
162
- "Limit": limit,
163
- "Recursive": "true",
164
- "Fields": "Path,MediaSources,MediaStreams,Size,RunTimeTicks,ProductionYear",
165
- "IncludeItemTypes": item_types,
166
- }
167
- resp = self._get(f"/Users/{uid}/Items", params=params)
168
- return resp.json().get("Items", [])
222
+ items: list[dict] = []
223
+ start = 0
224
+ page_size = 200
225
+ while True:
226
+ if limit is not None:
227
+ remaining = limit - len(items)
228
+ if remaining <= 0:
229
+ break
230
+ batch = min(page_size, remaining)
231
+ else:
232
+ batch = page_size
233
+ params = {
234
+ "SearchTerm": query,
235
+ "StartIndex": start,
236
+ "Limit": batch,
237
+ "Recursive": "true",
238
+ "Fields": ITEM_FIELDS,
239
+ "IncludeItemTypes": item_types,
240
+ }
241
+ resp = self._get(f"/Users/{uid}/Items", params=params)
242
+ page = resp.json()
243
+ chunk = page.get("Items", [])
244
+ items.extend(chunk)
245
+ total = page.get("TotalRecordCount", len(items))
246
+ start += len(chunk)
247
+ if not chunk or start >= total:
248
+ break
249
+ if limit is not None and len(items) >= limit:
250
+ break
251
+ if limit is not None:
252
+ return items[:limit]
253
+ return items
169
254
 
170
255
  def get_show_episodes(self, series_id: str, season: int | None = None) -> list[dict]:
171
256
  uid = self.resolve_user_id()
172
- params: dict = {
173
- "UserId": uid,
174
- "Fields": "Path,MediaSources,MediaStreams,Size,RunTimeTicks,ProductionYear",
175
- }
176
- if season is not None:
177
- params["Season"] = season
178
- resp = self._get(f"/Shows/{series_id}/Episodes", params=params)
179
- return resp.json().get("Items", [])
257
+ items: list[dict] = []
258
+ start = 0
259
+ batch = 200
260
+ while True:
261
+ params: dict = {
262
+ "UserId": uid,
263
+ "Fields": ITEM_FIELDS,
264
+ "StartIndex": start,
265
+ "Limit": batch,
266
+ }
267
+ if season is not None:
268
+ params["Season"] = season
269
+ resp = self._get(f"/Shows/{series_id}/Episodes", params=params)
270
+ page = resp.json()
271
+ chunk = page.get("Items", [])
272
+ items.extend(chunk)
273
+ total = page.get("TotalRecordCount", len(items))
274
+ start += batch
275
+ if start >= total or not chunk:
276
+ break
277
+ return items
180
278
 
181
279
  # -- playback ------------------------------------------------------------
182
280
 
@@ -197,8 +295,6 @@ class EmbyClient:
197
295
  }
198
296
  if media_source_id:
199
297
  params["MediaSourceId"] = media_source_id
200
- # Empty DeviceProfile: let the server decide; Emby Web sends one, but
201
- # DirectStreamUrl is still returned without it for compatible files.
202
298
  resp = self._request_with_retry(
203
299
  "POST",
204
300
  f"/Items/{item_id}/PlaybackInfo",
@@ -227,9 +323,8 @@ class EmbyClient:
227
323
  f"(SupportsTranscoding={source.get('SupportsTranscoding')}). "
228
324
  "Try --method download or --method hls."
229
325
  )
230
- device_id = _DEVICE_ID
231
326
  qs = {
232
- "DeviceId": device_id,
327
+ "DeviceId": _DEVICE_ID,
233
328
  "MediaSourceId": source.get("Id") or media_source_id or item_id,
234
329
  "PlaySessionId": play_session_id
235
330
  or hashlib.md5(f"stream-{item_id}-{time.time()}".encode()).hexdigest(),
@@ -239,13 +334,16 @@ class EmbyClient:
239
334
  direct = f"/Videos/{item_id}/original.{container}?{urlencode(qs)}"
240
335
 
241
336
  if direct.startswith("http"):
242
- return direct
243
- if not direct.startswith("/"):
244
- direct = "/" + direct
245
- # PlaybackInfo returns "/videos/..." (no /emby prefix)
246
- if direct.lower().startswith("/emby/"):
247
- return f"{self.server_url}{direct}"
248
- return f"{self.server_url}/emby{direct}"
337
+ url = direct
338
+ else:
339
+ if not direct.startswith("/"):
340
+ direct = "/" + direct
341
+ if direct.lower().startswith("/emby/"):
342
+ url = f"{self.server_url}{direct}"
343
+ else:
344
+ url = f"{self.server_url}/emby{direct}"
345
+
346
+ return self._ensure_api_key(url, self.access_token)
249
347
 
250
348
  # -- download ------------------------------------------------------------
251
349
 
@@ -256,6 +354,7 @@ class EmbyClient:
256
354
  chunk_size: int = DEFAULT_CHUNK,
257
355
  resume: bool = True,
258
356
  rate_bps: float | None = None,
357
+ expected_size: int | None = None,
259
358
  ) -> Path:
260
359
  """Download *url* to *dest_path* with optional resume and rate limit."""
261
360
  dest_path.parent.mkdir(parents=True, exist_ok=True)
@@ -272,9 +371,24 @@ class EmbyClient:
272
371
  try:
273
372
  resp = self.session.get(url, headers=headers, stream=True, timeout=30)
274
373
  if resp.status_code == 416:
374
+ # Range not satisfiable: complete only if size matches expectation
275
375
  if tmp_path.exists():
276
- tmp_path.rename(dest_path)
277
- return dest_path
376
+ size = tmp_path.stat().st_size
377
+ if expected_size is not None and size == expected_size:
378
+ tmp_path.rename(dest_path)
379
+ return dest_path
380
+ if expected_size is None and size > 0 and dest_path.exists() is False:
381
+ # Without Size, treat equal-to-dest or drop corrupt part
382
+ pass
383
+ print(f" Resume rejected (416); restarting download "
384
+ f"(part={size}, expected={expected_size})")
385
+ tmp_path.unlink(missing_ok=True)
386
+ existing = 0
387
+ headers = dict(self._auth_header())
388
+ continue
389
+ raise RuntimeError(
390
+ f"Server returned 416 with no partial file for {dest_path.name}"
391
+ )
278
392
  resp.raise_for_status()
279
393
  break
280
394
  except (requests.ConnectionError, requests.Timeout) as exc:
@@ -291,15 +405,15 @@ class EmbyClient:
291
405
  else:
292
406
  raise
293
407
 
408
+ mode = "ab" if existing and resp.status_code == 206 else "wb"
409
+ if mode == "wb":
410
+ existing = 0
411
+
294
412
  total = None
295
413
  cl = resp.headers.get("Content-Length")
296
414
  if cl:
297
415
  total = int(cl) + existing
298
416
 
299
- mode = "ab" if existing and resp.status_code == 206 else "wb"
300
- if mode == "wb":
301
- existing = 0
302
-
303
417
  if rate_bps:
304
418
  chunk_size = min(chunk_size, max(16384, int(rate_bps)))
305
419
 
@@ -338,6 +452,7 @@ class EmbyClient:
338
452
  chunk_size: int = DEFAULT_CHUNK,
339
453
  resume: bool = True,
340
454
  rate_bps: float | None = None,
455
+ expected_size: int | None = None,
341
456
  ) -> Path:
342
457
  """Download the original file for *item_id* via /Items/{id}/Download."""
343
458
  return self._download_from_url(
@@ -346,6 +461,7 @@ class EmbyClient:
346
461
  chunk_size=chunk_size,
347
462
  resume=resume,
348
463
  rate_bps=rate_bps,
464
+ expected_size=expected_size,
349
465
  )
350
466
 
351
467
  def download_item_stream(
@@ -355,6 +471,7 @@ class EmbyClient:
355
471
  chunk_size: int = DEFAULT_CHUNK,
356
472
  resume: bool = True,
357
473
  rate_bps: float | None = None,
474
+ expected_size: int | None = None,
358
475
  ) -> Path:
359
476
  """Download like Emby Web playback: PlaybackInfo + /videos/{id}/original.*."""
360
477
  url = self.resolve_direct_stream_url(item_id)
@@ -364,6 +481,7 @@ class EmbyClient:
364
481
  chunk_size=chunk_size,
365
482
  resume=resume,
366
483
  rate_bps=rate_bps,
484
+ expected_size=expected_size,
367
485
  )
368
486
 
369
487
  # -- HLS download --------------------------------------------------------
@@ -406,17 +524,15 @@ class EmbyClient:
406
524
  raise RuntimeError(f"No media sources for item {item_id}")
407
525
  media_source_id = sources[0]["Id"]
408
526
 
409
- device_id = _DEVICE_ID
410
527
  play_session_id = hashlib.md5(
411
528
  f"hls-{item_id}-{time.time()}".encode()
412
529
  ).hexdigest()
413
530
 
531
+ # Omit VideoCodec/AudioCodec=copy (not valid Emby codec ids); let server decide.
414
532
  hls_params: dict = {
415
- "DeviceId": device_id,
533
+ "DeviceId": _DEVICE_ID,
416
534
  "MediaSourceId": media_source_id,
417
535
  "PlaySessionId": play_session_id,
418
- "VideoCodec": "copy",
419
- "AudioCodec": "copy",
420
536
  "SegmentContainer": "ts",
421
537
  "BreakOnNonKeyFrames": "false",
422
538
  }
@@ -431,10 +547,9 @@ class EmbyClient:
431
547
  if not master.playlists:
432
548
  raise RuntimeError("No variant streams in master playlist")
433
549
 
434
- variant_uri = master.playlists[0].absolute_uri
435
- if self.access_token and "api_key" not in variant_uri:
436
- sep = "&" if "?" in variant_uri else "?"
437
- variant_uri += f"{sep}api_key={self.access_token}"
550
+ variant_uri = self._ensure_api_key(
551
+ master.playlists[0].absolute_uri, self.access_token
552
+ )
438
553
 
439
554
  tmp_dir = dest_path.parent / f".hls-tmp-{item_id}"
440
555
  tmp_dir.mkdir(parents=True, exist_ok=True)
@@ -460,11 +575,7 @@ class EmbyClient:
460
575
  continue
461
576
  seen_uris.add(seg_uri)
462
577
 
463
- dl_uri = seg_uri
464
- if self.access_token and "api_key" not in dl_uri:
465
- sep = "&" if "?" in dl_uri else "?"
466
- dl_uri += f"{sep}api_key={self.access_token}"
467
-
578
+ dl_uri = self._ensure_api_key(seg_uri, self.access_token)
468
579
  seg_path = tmp_dir / f"seg_{len(segments):06d}.ts"
469
580
  self._download_segment(dl_uri, seg_path)
470
581
  segments.append(seg_path)