emby-cli 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """emby-cli — backup and stream media from an Emby server."""
2
+
3
+ from emby_cli.client import EmbyClient
4
+ from emby_cli.version import get_version
5
+
6
+ __all__ = ["EmbyClient", "get_version"]
emby_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from emby_cli.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
emby_cli/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
emby_cli/cli.py ADDED
@@ -0,0 +1,134 @@
1
+ """Argument parser and entrypoint for emby-cli."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ import warnings
9
+
10
+ # macOS system Python ships LibreSSL; urllib3 v2 only warns, TLS still works.
11
+ warnings.filterwarnings("ignore", message="urllib3 v2 only supports OpenSSL")
12
+
13
+ import requests
14
+
15
+ from emby_cli.client import EmbyClient
16
+ from emby_cli.commands.batch import cmd_batch
17
+ from emby_cli.commands.download import cmd_download
18
+ from emby_cli.commands.list import cmd_list
19
+ from emby_cli.commands.play import cmd_play
20
+ from emby_cli.commands.search import cmd_search
21
+ from emby_cli.commands.sync import cmd_sync
22
+ from emby_cli.constants import DEFAULT_OUTPUT
23
+
24
+
25
+ def build_parser() -> argparse.ArgumentParser:
26
+ env = os.environ.get
27
+
28
+ p = argparse.ArgumentParser(
29
+ prog="emby-cli",
30
+ description="Download / backup original media files from an Emby server via its REST API.",
31
+ )
32
+ p.add_argument("--server", "-s", default=env("EMBY_SERVER"), help="Emby server URL (env: EMBY_SERVER)")
33
+ p.add_argument("--api-key", "-k", default=env("EMBY_API_KEY"), help="API key (env: EMBY_API_KEY)")
34
+ p.add_argument("--username", "-u", default=env("EMBY_USERNAME"), help="Username (env: EMBY_USERNAME)")
35
+ p.add_argument("--password", "-p", default=env("EMBY_PASSWORD"), help="Password (env: EMBY_PASSWORD)")
36
+
37
+ sub = p.add_subparsers(dest="command", required=True)
38
+
39
+ ls = sub.add_parser("list", help="List libraries or items in a library")
40
+ ls.add_argument("--library", "-l", help="Library name to list items for")
41
+
42
+ dl = sub.add_parser("download", help="Download items")
43
+ dl.add_argument("--library", "-l", help="Library name to download")
44
+ dl.add_argument("--item-id", "-i", default=env("EMBY_ITEM_ID"), help="Specific item ID to download (env: EMBY_ITEM_ID)")
45
+ dl.add_argument("--output", "-o", default=env("EMBY_OUTPUT", DEFAULT_OUTPUT),
46
+ 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")
48
+ dl.add_argument("--throttle", "-t", type=float, nargs="?", const=1.0, default=0,
49
+ help="Limit speed to playback rate. Optional multiplier: 1=realtime, 1.5=50%% faster (default: off)")
50
+ dl.add_argument("--method", "-m", default=env("EMBY_METHOD", "download"),
51
+ choices=["download", "stream", "hls"],
52
+ help="Download method: 'download' (API Download), 'stream' (browser-like original.*), "
53
+ "or 'hls' (stream chunks + remux) (env: EMBY_METHOD)")
54
+
55
+ sr = sub.add_parser("search", help="Search for items by name")
56
+ sr.add_argument("query", help="Search query")
57
+
58
+ pl = sub.add_parser("play", help="Play an item via DirectStreamUrl in an external player")
59
+ pl.add_argument("query", nargs="?",
60
+ help="Title line like batch: 'Movie (2010)' or 'Show (2000) S01E01'")
61
+ pl.add_argument("--item-id", "-i", default=env("EMBY_ITEM_ID"),
62
+ help="Item ID to play (env: EMBY_ITEM_ID)")
63
+ pl.add_argument("--player", default=env("EMBY_PLAYER"),
64
+ help="External player command or path (env: EMBY_PLAYER), e.g. vlc or "
65
+ "/Applications/VLC.app/Contents/MacOS/VLC")
66
+ pl.add_argument("--wait", action="store_true",
67
+ help="Block until the player process exits (default: detach and return)")
68
+ 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")
71
+
72
+ sy = sub.add_parser("sync", help="Sync all libraries (or one with --library)")
73
+ sy.add_argument("--library", "-l", default=env("EMBY_LIBRARY"), help="Specific library to sync (env: EMBY_LIBRARY)")
74
+ sy.add_argument("--output", "-o", default=env("EMBY_OUTPUT", DEFAULT_OUTPUT),
75
+ 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")
77
+ sy.add_argument("--throttle", "-t", type=float, nargs="?", const=1.0, default=0,
78
+ help="Limit speed to playback rate. Optional multiplier: 1=realtime, 1.5=50%% faster (default: off)")
79
+ sy.add_argument("--method", "-m", default=env("EMBY_METHOD", "download"),
80
+ choices=["download", "stream", "hls"],
81
+ help="Download method: 'download' (API Download), 'stream' (browser-like original.*), "
82
+ "or 'hls' (stream chunks + remux) (env: EMBY_METHOD)")
83
+
84
+ ba = sub.add_parser("batch", help="Download titles from a text file (movies, seasons, episodes)")
85
+ ba.add_argument("--file", "-F", required=True, help="Text file with one title per line")
86
+ ba.add_argument("--dry-run", "-n", action="store_true", help="Search and select only, do not download")
87
+ ba.add_argument("--output", "-o", default=env("EMBY_OUTPUT", DEFAULT_OUTPUT),
88
+ 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")
90
+ ba.add_argument("--throttle", "-t", type=float, nargs="?", const=1.0, default=0,
91
+ help="Limit speed to playback rate. Optional multiplier: 1=realtime, 1.5=50%% faster (default: off)")
92
+ ba.add_argument("--method", "-m", default=env("EMBY_METHOD", "download"),
93
+ choices=["download", "stream", "hls"],
94
+ help="Download method: 'download' (API Download), 'stream' (browser-like original.*), "
95
+ "or 'hls' (stream chunks + remux) (env: EMBY_METHOD)")
96
+
97
+ return p
98
+
99
+
100
+ def main() -> None:
101
+ parser = build_parser()
102
+ args = parser.parse_args()
103
+
104
+ if not args.server:
105
+ parser.error("Provide --server or set EMBY_SERVER")
106
+
107
+ if not args.api_key and not args.username:
108
+ parser.error("Provide --api-key / EMBY_API_KEY or --username / EMBY_USERNAME")
109
+
110
+ client = EmbyClient(args.server, api_key=args.api_key)
111
+
112
+ if args.username is not None:
113
+ pw = args.password if args.password is not None else ""
114
+ print(f"Authenticating as '{args.username}'...")
115
+ try:
116
+ client.authenticate(args.username, pw)
117
+ except requests.HTTPError as exc:
118
+ print(f"Authentication failed: {exc}")
119
+ sys.exit(1)
120
+ print("OK\n")
121
+
122
+ commands = {
123
+ "list": cmd_list,
124
+ "download": cmd_download,
125
+ "search": cmd_search,
126
+ "play": cmd_play,
127
+ "sync": cmd_sync,
128
+ "batch": cmd_batch,
129
+ }
130
+ commands[args.command](client, args)
131
+
132
+
133
+ if __name__ == "__main__":
134
+ main()
emby_cli/client.py ADDED
@@ -0,0 +1,499 @@
1
+ """Emby REST API client: auth, browse, download, stream, HLS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import shutil
7
+ import time
8
+ from pathlib import Path
9
+ from urllib.parse import urlencode
10
+
11
+ import m3u8
12
+ import requests
13
+ from tqdm import tqdm
14
+
15
+ from emby_cli.constants import (
16
+ CLIENT_NAME,
17
+ DEFAULT_CHUNK,
18
+ DEVICE_NAME,
19
+ MAX_RETRIES,
20
+ RETRY_BACKOFF_BASE,
21
+ )
22
+ from emby_cli.util import remux_segments
23
+
24
+ _DEVICE_ID = hashlib.md5(CLIENT_NAME.encode()).hexdigest()
25
+
26
+
27
+ class EmbyClient:
28
+ def __init__(self, server_url: str, api_key: str | None = None):
29
+ self.server_url = server_url.rstrip("/")
30
+ self.api_key = api_key
31
+ self.user_id: str | None = None
32
+ self.access_token: str | None = api_key
33
+ self.session = requests.Session()
34
+ self.session.headers.update({
35
+ "X-Emby-Client": CLIENT_NAME,
36
+ "X-Emby-Device-Name": DEVICE_NAME,
37
+ "X-Emby-Device-Id": _DEVICE_ID,
38
+ "X-Emby-Client-Version": "1.0.0",
39
+ })
40
+
41
+ # -- helpers -------------------------------------------------------------
42
+
43
+ def _url(self, path: str) -> str:
44
+ return f"{self.server_url}/emby{path}" if not path.startswith("http") else path
45
+
46
+ def _auth_header(self) -> dict:
47
+ parts = [
48
+ f'MediaBrowser Client="{CLIENT_NAME}"',
49
+ f'Device="{DEVICE_NAME}"',
50
+ f'DeviceId="{_DEVICE_ID}"',
51
+ 'Version="1.0.0"',
52
+ ]
53
+ if self.access_token:
54
+ parts.append(f'Token="{self.access_token}"')
55
+ return {"X-Emby-Authorization": ", ".join(parts)}
56
+
57
+ def _request_with_retry(self, method: str, path: str, **kwargs) -> requests.Response:
58
+ kwargs.setdefault("headers", self._auth_header())
59
+ url = self._url(path)
60
+ for attempt in range(1, MAX_RETRIES + 1):
61
+ try:
62
+ resp = self.session.request(method, url, **kwargs)
63
+ resp.raise_for_status()
64
+ return resp
65
+ except (requests.ConnectionError, requests.Timeout) as exc:
66
+ if attempt == MAX_RETRIES:
67
+ raise
68
+ wait = RETRY_BACKOFF_BASE * (2 ** (attempt - 1))
69
+ print(f" Connection error (attempt {attempt}/{MAX_RETRIES}), retrying in {wait}s: {exc}")
70
+ time.sleep(wait)
71
+ except requests.HTTPError as exc:
72
+ if resp.status_code >= 500 and attempt < MAX_RETRIES:
73
+ wait = RETRY_BACKOFF_BASE * (2 ** (attempt - 1))
74
+ print(f" Server error {resp.status_code} (attempt {attempt}/{MAX_RETRIES}), retrying in {wait}s")
75
+ time.sleep(wait)
76
+ else:
77
+ raise
78
+ raise RuntimeError("unreachable")
79
+
80
+ def _get(self, path: str, params: dict | None = None, **kwargs) -> requests.Response:
81
+ return self._request_with_retry("GET", path, params=params, **kwargs)
82
+
83
+ def _post(self, path: str, payload: dict | None = None) -> requests.Response:
84
+ return self._request_with_retry("POST", path, json=payload)
85
+
86
+ # -- auth ----------------------------------------------------------------
87
+
88
+ def authenticate(self, username: str, password: str) -> None:
89
+ data = {"Username": username, "Pw": password}
90
+ resp = self._post("/Users/AuthenticateByName", data)
91
+ body = resp.json()
92
+ self.access_token = body["AccessToken"]
93
+ self.user_id = body["User"]["Id"]
94
+
95
+ def resolve_user_id(self) -> str:
96
+ if self.user_id:
97
+ return self.user_id
98
+ users = self._get("/Users").json()
99
+ if not users:
100
+ raise RuntimeError("No users found on server")
101
+ self.user_id = users[0]["Id"]
102
+ return self.user_id
103
+
104
+ # -- browse --------------------------------------------------------------
105
+
106
+ def get_libraries(self) -> list[dict]:
107
+ uid = self.resolve_user_id()
108
+ resp = self._get(f"/Users/{uid}/Views")
109
+ return resp.json().get("Items", [])
110
+
111
+ def get_items(
112
+ self,
113
+ parent_id: str | None = None,
114
+ item_type: str | None = None,
115
+ recursive: bool = True,
116
+ start: int = 0,
117
+ limit: int = 200,
118
+ ) -> dict:
119
+ uid = self.resolve_user_id()
120
+ params: dict = {
121
+ "StartIndex": start,
122
+ "Limit": limit,
123
+ "Recursive": str(recursive).lower(),
124
+ "Fields": "Path,MediaSources,DateCreated,Size,RunTimeTicks",
125
+ "SortBy": "SortName",
126
+ "SortOrder": "Ascending",
127
+ }
128
+ if parent_id:
129
+ params["ParentId"] = parent_id
130
+ if item_type:
131
+ params["IncludeItemTypes"] = item_type
132
+ resp = self._get(f"/Users/{uid}/Items", params=params)
133
+ return resp.json()
134
+
135
+ def get_all_items(
136
+ self,
137
+ parent_id: str | None = None,
138
+ item_type: str | None = None,
139
+ ) -> list[dict]:
140
+ """Page through all items and return the full list."""
141
+ items: list[dict] = []
142
+ start = 0
143
+ batch = 200
144
+ while True:
145
+ page = self.get_items(parent_id=parent_id, item_type=item_type, start=start, limit=batch)
146
+ items.extend(page.get("Items", []))
147
+ total = page.get("TotalRecordCount", 0)
148
+ start += batch
149
+ if start >= total:
150
+ break
151
+ return items
152
+
153
+ def get_item_info(self, item_id: str) -> dict:
154
+ uid = self.resolve_user_id()
155
+ resp = self._get(f"/Users/{uid}/Items/{item_id}")
156
+ return resp.json()
157
+
158
+ def search_items(self, query: str, item_types: str = "Movie", limit: int = 25) -> list[dict]:
159
+ 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", [])
169
+
170
+ def get_show_episodes(self, series_id: str, season: int | None = None) -> list[dict]:
171
+ 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", [])
180
+
181
+ # -- playback ------------------------------------------------------------
182
+
183
+ def get_playback_info(
184
+ self,
185
+ item_id: str,
186
+ media_source_id: str | None = None,
187
+ max_bitrate: int = 120_000_000,
188
+ ) -> dict:
189
+ """Ask the server how this client should play *item_id* (like Emby Web)."""
190
+ uid = self.resolve_user_id()
191
+ params: dict = {
192
+ "UserId": uid,
193
+ "StartTimeTicks": 0,
194
+ "IsPlayback": "true",
195
+ "AutoOpenLiveStream": "true",
196
+ "MaxStreamingBitrate": max_bitrate,
197
+ }
198
+ if media_source_id:
199
+ 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
+ resp = self._request_with_retry(
203
+ "POST",
204
+ f"/Items/{item_id}/PlaybackInfo",
205
+ params=params,
206
+ json={},
207
+ )
208
+ return resp.json()
209
+
210
+ def resolve_direct_stream_url(self, item_id: str) -> str:
211
+ """Return an absolute DirectStreamUrl (browser-style original.* stream)."""
212
+ item_info = self.get_item_info(item_id)
213
+ sources = item_info.get("MediaSources") or []
214
+ media_source_id = sources[0]["Id"] if sources else None
215
+ container = (sources[0].get("Container") if sources else None) or "mp4"
216
+
217
+ info = self.get_playback_info(item_id, media_source_id=media_source_id)
218
+ play_session_id = info.get("PlaySessionId")
219
+ pb_sources = info.get("MediaSources") or []
220
+ source = pb_sources[0] if pb_sources else {}
221
+
222
+ direct = source.get("DirectStreamUrl")
223
+ if not direct:
224
+ if not source.get("SupportsDirectStream", True):
225
+ raise RuntimeError(
226
+ f"Item {item_id} has no DirectStreamUrl "
227
+ f"(SupportsTranscoding={source.get('SupportsTranscoding')}). "
228
+ "Try --method download or --method hls."
229
+ )
230
+ device_id = _DEVICE_ID
231
+ qs = {
232
+ "DeviceId": device_id,
233
+ "MediaSourceId": source.get("Id") or media_source_id or item_id,
234
+ "PlaySessionId": play_session_id
235
+ or hashlib.md5(f"stream-{item_id}-{time.time()}".encode()).hexdigest(),
236
+ }
237
+ if self.access_token:
238
+ qs["api_key"] = self.access_token
239
+ direct = f"/Videos/{item_id}/original.{container}?{urlencode(qs)}"
240
+
241
+ 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}"
249
+
250
+ # -- download ------------------------------------------------------------
251
+
252
+ def _download_from_url(
253
+ self,
254
+ url: str,
255
+ dest_path: Path,
256
+ chunk_size: int = DEFAULT_CHUNK,
257
+ resume: bool = True,
258
+ rate_bps: float | None = None,
259
+ ) -> Path:
260
+ """Download *url* to *dest_path* with optional resume and rate limit."""
261
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
262
+ tmp_path = dest_path.with_suffix(dest_path.suffix + ".part")
263
+
264
+ headers = dict(self._auth_header())
265
+ existing = 0
266
+ if resume and tmp_path.exists():
267
+ existing = tmp_path.stat().st_size
268
+ headers["Range"] = f"bytes={existing}-"
269
+
270
+ resp = None
271
+ for attempt in range(1, MAX_RETRIES + 1):
272
+ try:
273
+ resp = self.session.get(url, headers=headers, stream=True, timeout=30)
274
+ if resp.status_code == 416:
275
+ if tmp_path.exists():
276
+ tmp_path.rename(dest_path)
277
+ return dest_path
278
+ resp.raise_for_status()
279
+ break
280
+ except (requests.ConnectionError, requests.Timeout) as exc:
281
+ if attempt == MAX_RETRIES:
282
+ raise
283
+ wait = RETRY_BACKOFF_BASE * (2 ** (attempt - 1))
284
+ print(f" Connection error (attempt {attempt}/{MAX_RETRIES}), retrying in {wait}s: {exc}")
285
+ time.sleep(wait)
286
+ except requests.HTTPError:
287
+ if resp is not None and resp.status_code >= 500 and attempt < MAX_RETRIES:
288
+ wait = RETRY_BACKOFF_BASE * (2 ** (attempt - 1))
289
+ print(f" Server error {resp.status_code} (attempt {attempt}/{MAX_RETRIES}), retrying in {wait}s")
290
+ time.sleep(wait)
291
+ else:
292
+ raise
293
+
294
+ total = None
295
+ cl = resp.headers.get("Content-Length")
296
+ if cl:
297
+ total = int(cl) + existing
298
+
299
+ mode = "ab" if existing and resp.status_code == 206 else "wb"
300
+ if mode == "wb":
301
+ existing = 0
302
+
303
+ if rate_bps:
304
+ chunk_size = min(chunk_size, max(16384, int(rate_bps)))
305
+
306
+ t0 = time.monotonic()
307
+ written = 0
308
+
309
+ with (
310
+ open(tmp_path, mode) as fh,
311
+ tqdm(
312
+ total=total,
313
+ initial=existing,
314
+ unit="B",
315
+ unit_scale=True,
316
+ desc=dest_path.name,
317
+ leave=True,
318
+ ) as bar,
319
+ ):
320
+ for chunk in resp.iter_content(chunk_size=chunk_size):
321
+ fh.write(chunk)
322
+ written += len(chunk)
323
+ bar.update(len(chunk))
324
+
325
+ if rate_bps:
326
+ expected_elapsed = written / rate_bps
327
+ actual_elapsed = time.monotonic() - t0
328
+ if actual_elapsed < expected_elapsed:
329
+ time.sleep(expected_elapsed - actual_elapsed)
330
+
331
+ tmp_path.rename(dest_path)
332
+ return dest_path
333
+
334
+ def download_item(
335
+ self,
336
+ item_id: str,
337
+ dest_path: Path,
338
+ chunk_size: int = DEFAULT_CHUNK,
339
+ resume: bool = True,
340
+ rate_bps: float | None = None,
341
+ ) -> Path:
342
+ """Download the original file for *item_id* via /Items/{id}/Download."""
343
+ return self._download_from_url(
344
+ self._url(f"/Items/{item_id}/Download"),
345
+ dest_path,
346
+ chunk_size=chunk_size,
347
+ resume=resume,
348
+ rate_bps=rate_bps,
349
+ )
350
+
351
+ def download_item_stream(
352
+ self,
353
+ item_id: str,
354
+ dest_path: Path,
355
+ chunk_size: int = DEFAULT_CHUNK,
356
+ resume: bool = True,
357
+ rate_bps: float | None = None,
358
+ ) -> Path:
359
+ """Download like Emby Web playback: PlaybackInfo + /videos/{id}/original.*."""
360
+ url = self.resolve_direct_stream_url(item_id)
361
+ return self._download_from_url(
362
+ url,
363
+ dest_path,
364
+ chunk_size=chunk_size,
365
+ resume=resume,
366
+ rate_bps=rate_bps,
367
+ )
368
+
369
+ # -- HLS download --------------------------------------------------------
370
+
371
+ def _download_segment(self, url: str, dest: Path) -> None:
372
+ """Download a single HLS segment with retry."""
373
+ resp = None
374
+ for attempt in range(1, MAX_RETRIES + 1):
375
+ try:
376
+ resp = self.session.get(
377
+ url, headers=self._auth_header(), stream=True, timeout=30,
378
+ )
379
+ resp.raise_for_status()
380
+ with open(dest, "wb") as f:
381
+ for data in resp.iter_content(chunk_size=DEFAULT_CHUNK):
382
+ f.write(data)
383
+ return
384
+ except (requests.ConnectionError, requests.Timeout) as exc:
385
+ if attempt == MAX_RETRIES:
386
+ raise
387
+ wait = RETRY_BACKOFF_BASE * (2 ** (attempt - 1))
388
+ print(f" Segment retry ({attempt}/{MAX_RETRIES}), waiting {wait}s")
389
+ time.sleep(wait)
390
+ except requests.HTTPError:
391
+ if resp is not None and resp.status_code >= 500 and attempt < MAX_RETRIES:
392
+ wait = RETRY_BACKOFF_BASE * (2 ** (attempt - 1))
393
+ print(f" Segment error {resp.status_code} ({attempt}/{MAX_RETRIES}), waiting {wait}s")
394
+ time.sleep(wait)
395
+ else:
396
+ raise
397
+
398
+ def download_item_hls(self, item_id: str, dest_path: Path, throttle: float = 0) -> Path:
399
+ """Download via HLS chunks (like a web player) and remux to mkv."""
400
+ dest_path = dest_path.with_suffix(".mkv")
401
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
402
+
403
+ item_info = self.get_item_info(item_id)
404
+ sources = item_info.get("MediaSources", [])
405
+ if not sources:
406
+ raise RuntimeError(f"No media sources for item {item_id}")
407
+ media_source_id = sources[0]["Id"]
408
+
409
+ device_id = _DEVICE_ID
410
+ play_session_id = hashlib.md5(
411
+ f"hls-{item_id}-{time.time()}".encode()
412
+ ).hexdigest()
413
+
414
+ hls_params: dict = {
415
+ "DeviceId": device_id,
416
+ "MediaSourceId": media_source_id,
417
+ "PlaySessionId": play_session_id,
418
+ "VideoCodec": "copy",
419
+ "AudioCodec": "copy",
420
+ "SegmentContainer": "ts",
421
+ "BreakOnNonKeyFrames": "false",
422
+ }
423
+ if self.access_token:
424
+ hls_params["api_key"] = self.access_token
425
+
426
+ master_path = f"/Videos/{item_id}/master.m3u8"
427
+ master_full_url = self._url(master_path)
428
+ resp = self._get(master_path, params=hls_params)
429
+ master = m3u8.loads(resp.text, uri=master_full_url)
430
+
431
+ if not master.playlists:
432
+ raise RuntimeError("No variant streams in master playlist")
433
+
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}"
438
+
439
+ tmp_dir = dest_path.parent / f".hls-tmp-{item_id}"
440
+ tmp_dir.mkdir(parents=True, exist_ok=True)
441
+
442
+ try:
443
+ segments: list[Path] = []
444
+ seen_uris: set[str] = set()
445
+ playback_clock = 0.0
446
+ t0 = time.monotonic()
447
+ bar = tqdm(unit=" seg", desc=dest_path.name, leave=True)
448
+
449
+ while True:
450
+ resp = self._get(variant_uri)
451
+ media = m3u8.loads(resp.text, uri=variant_uri)
452
+
453
+ if media.is_endlist and bar.total is None:
454
+ bar.total = len(media.segments)
455
+ bar.refresh()
456
+
457
+ for seg in media.segments:
458
+ seg_uri = seg.absolute_uri
459
+ if seg_uri in seen_uris:
460
+ continue
461
+ seen_uris.add(seg_uri)
462
+
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
+
468
+ seg_path = tmp_dir / f"seg_{len(segments):06d}.ts"
469
+ self._download_segment(dl_uri, seg_path)
470
+ segments.append(seg_path)
471
+ bar.update(1)
472
+
473
+ if throttle and seg.duration:
474
+ playback_clock += seg.duration / throttle
475
+ real_elapsed = time.monotonic() - t0
476
+ if real_elapsed < playback_clock:
477
+ time.sleep(playback_clock - real_elapsed)
478
+
479
+ if media.is_endlist:
480
+ break
481
+
482
+ sleep_time = media.target_duration or 3
483
+ time.sleep(sleep_time)
484
+
485
+ bar.close()
486
+
487
+ if not segments:
488
+ raise RuntimeError("No segments were downloaded")
489
+
490
+ print(f" Remuxing {len(segments)} segments -> {dest_path.name}")
491
+ remux_segments(tmp_dir, segments, dest_path)
492
+
493
+ done_marker = Path(str(dest_path) + ".done")
494
+ done_marker.write_text(str(len(segments)))
495
+
496
+ finally:
497
+ shutil.rmtree(tmp_dir, ignore_errors=True)
498
+
499
+ return dest_path
@@ -0,0 +1 @@
1
+ """CLI command handlers."""