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.
@@ -0,0 +1,244 @@
1
+ """batch command — download titles from a text file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import re
7
+ import time
8
+ from pathlib import Path
9
+
10
+ from emby_cli.client import EmbyClient
11
+ from emby_cli.download_ops import do_download, should_skip_item
12
+ from emby_cli.resolve import (
13
+ classify_resolution,
14
+ item_video_width,
15
+ parse_title_line,
16
+ pick_best_item,
17
+ )
18
+ from emby_cli.util import (
19
+ build_dest_path,
20
+ format_duration,
21
+ format_size,
22
+ item_remote_size,
23
+ )
24
+
25
+ def cmd_batch(client: EmbyClient, args: argparse.Namespace) -> None:
26
+ """Download titles listed in a text file, with resolution prioritisation."""
27
+ output = Path(args.output)
28
+ throttle = getattr(args, "throttle", 0)
29
+ method = getattr(args, "method", "download")
30
+ dry_run = getattr(args, "dry_run", False)
31
+
32
+ if dry_run:
33
+ print("*** DRY RUN — no files will be downloaded ***\n")
34
+
35
+ file_path = Path(args.file)
36
+ lines: list[str] = []
37
+ with open(file_path) as f:
38
+ for raw in f:
39
+ raw = raw.strip()
40
+ if raw and not raw.startswith("#"):
41
+ lines.append(raw)
42
+
43
+ if not lines:
44
+ print("No titles found in file.")
45
+ return
46
+
47
+ print(f"Loaded {len(lines)} titles from {file_path}\n")
48
+
49
+ stats: list[tuple[str, str, float, str]] = []
50
+ total_t0 = time.monotonic()
51
+
52
+ for idx, raw_line in enumerate(lines, 1):
53
+ emby_id_match = re.match(r"^embyId=(\S+)$", raw_line.strip(), re.IGNORECASE)
54
+ if emby_id_match:
55
+ item_id = emby_id_match.group(1)
56
+ label = f"embyId={item_id}"
57
+ print(f"\n[{idx}/{len(lines)}] Direct ID: {item_id}")
58
+ line_t0 = time.monotonic()
59
+ try:
60
+ item = client.get_item_info(item_id)
61
+ res = classify_resolution(item_video_width(item))
62
+ size = format_size(item_remote_size(item))
63
+ dest = build_dest_path(item, output)
64
+ print(f" {item['Name']} ({item.get('ProductionYear','?')}) [{res}, {size}]")
65
+
66
+ if dry_run:
67
+ print(f" Would download: {item['Name']} [{res}] -> {dest}")
68
+ stats.append((label, "DRY RUN", 0, item['Name']))
69
+ continue
70
+
71
+ if should_skip_item(item, dest, method, args.force):
72
+ elapsed = time.monotonic() - line_t0
73
+ print(f" Skipping (already downloaded)")
74
+ stats.append((label, "SKIPPED", elapsed, item['Name']))
75
+ continue
76
+
77
+ print(f" Downloading -> {dest}")
78
+ do_download(client, item_id, item, dest, method, throttle)
79
+ elapsed = time.monotonic() - line_t0
80
+ print(f" Time: {format_duration(elapsed)}")
81
+ stats.append((label, "OK", elapsed, item['Name']))
82
+ except Exception as exc:
83
+ elapsed = time.monotonic() - line_t0
84
+ print(f" ERROR: {exc}")
85
+ stats.append((label, "ERROR", elapsed, str(exc)))
86
+ continue
87
+
88
+ title, season, episode, year = parse_title_line(raw_line)
89
+
90
+ if season is not None:
91
+ kind = f"S{season:02d}E{episode:02d}" if episode else f"S{season:02d}"
92
+ label = f"{title} ({year}) {kind}" if year else f"{title} {kind}"
93
+ search_type = "series"
94
+ else:
95
+ label = f"{title} ({year})" if year else title
96
+ search_type = "movie"
97
+
98
+ print(f"\n[{idx}/{len(lines)}] Searching: \"{label}\" ({search_type})")
99
+ line_t0 = time.monotonic()
100
+
101
+ try:
102
+ if search_type == "movie":
103
+ results = client.search_items(title, item_types="Movie")
104
+ if not results:
105
+ print(" No results found, skipping.")
106
+ stats.append((label, "NOT FOUND", 0, ""))
107
+ continue
108
+
109
+ candidates = results
110
+ if year is not None:
111
+ year_matches = [r for r in results if r.get("ProductionYear") == year]
112
+ if year_matches:
113
+ print(f" Year filter: {year} ({len(year_matches)}/{len(results)} match)")
114
+ candidates = year_matches
115
+ else:
116
+ print(f" WARNING: no results match year {year}, using all {len(results)} results")
117
+
118
+ best = pick_best_item(candidates)
119
+ for r in results:
120
+ res = classify_resolution(item_video_width(r))
121
+ size = format_size(item_remote_size(r))
122
+ selected = " <-- selected" if r is best else ""
123
+ excluded = " (excluded by year)" if year and r not in candidates else ""
124
+ print(f" - {r['Name']} ({r.get('ProductionYear','?')}) [{res}, {size}]{selected}{excluded}")
125
+
126
+ res = classify_resolution(item_video_width(best))
127
+ dest = build_dest_path(best, output)
128
+
129
+ if dry_run:
130
+ print(f" Would download: {best['Name']} [{res}] -> {dest}")
131
+ stats.append((label, "DRY RUN", 0, ""))
132
+ continue
133
+
134
+ if should_skip_item(best, dest, method, args.force):
135
+ elapsed = time.monotonic() - line_t0
136
+ print(f" Skipping (already downloaded)")
137
+ stats.append((label, "SKIPPED", elapsed, ""))
138
+ continue
139
+
140
+ print(f" Downloading: {best['Name']} [{res}] -> {dest}")
141
+ do_download(client, best["Id"], best, dest, method, throttle)
142
+ elapsed = time.monotonic() - line_t0
143
+ print(f" Time: {format_duration(elapsed)}")
144
+ stats.append((label, "OK", elapsed, ""))
145
+
146
+ else:
147
+ series_results = client.search_items(title, item_types="Series")
148
+ if not series_results:
149
+ print(" No series found, skipping.")
150
+ stats.append((label, "NOT FOUND", 0, ""))
151
+ continue
152
+
153
+ if year is not None:
154
+ year_matches = [s for s in series_results if s.get("ProductionYear") == year]
155
+ if year_matches:
156
+ print(f" Year filter: {year} ({len(year_matches)}/{len(series_results)} match)")
157
+ for s in series_results:
158
+ tag = " <-- selected" if s is year_matches[0] else ""
159
+ excluded = " (excluded by year)" if s not in year_matches else ""
160
+ print(f" - {s['Name']} ({s.get('ProductionYear','?')}){tag}{excluded}")
161
+ series = year_matches[0]
162
+ else:
163
+ print(f" WARNING: no series match year {year}, using first result")
164
+ series = series_results[0]
165
+ else:
166
+ series = series_results[0]
167
+
168
+ print(f" Found series: {series['Name']} ({series.get('ProductionYear','?')})")
169
+
170
+ episodes = client.get_show_episodes(series["Id"], season=season)
171
+ if episode is not None:
172
+ episodes = [e for e in episodes if e.get("IndexNumber") == episode]
173
+
174
+ if not episodes:
175
+ print(f" No episodes found for {kind}, skipping.")
176
+ stats.append((label, "NOT FOUND", 0, ""))
177
+ continue
178
+
179
+ print(f" {len(episodes)} episode(s)")
180
+
181
+ if dry_run:
182
+ for ep_idx, ep in enumerate(episodes, 1):
183
+ ep_num = f"S{ep.get('ParentIndexNumber', 0):02d}E{ep.get('IndexNumber', 0):02d}"
184
+ ep_name = ep.get("Name", "?")
185
+ res = classify_resolution(item_video_width(ep))
186
+ size = format_size(item_remote_size(ep))
187
+ print(f" [{ep_idx}/{len(episodes)}] {ep_num} {ep_name} [{res}, {size}]")
188
+ stats.append((label, "DRY RUN", 0, f"{len(episodes)} episodes"))
189
+ continue
190
+
191
+ ep_errors = 0
192
+
193
+ for ep_idx, ep in enumerate(episodes, 1):
194
+ ep_num = f"S{ep.get('ParentIndexNumber', 0):02d}E{ep.get('IndexNumber', 0):02d}"
195
+ ep_name = ep.get("Name", "?")
196
+ res = classify_resolution(item_video_width(ep))
197
+ size = format_size(item_remote_size(ep))
198
+ dest = build_dest_path(ep, output)
199
+
200
+ if should_skip_item(ep, dest, method, args.force):
201
+ print(f" [{ep_idx}/{len(episodes)}] {ep_num} {ep_name} [{res}] - skipped")
202
+ continue
203
+
204
+ print(f" [{ep_idx}/{len(episodes)}] {ep_num} {ep_name} [{res}, {size}]")
205
+ try:
206
+ do_download(client, ep["Id"], ep, dest, method, throttle)
207
+ except Exception as exc:
208
+ print(f" ERROR: {exc}")
209
+ ep_errors += 1
210
+
211
+ elapsed = time.monotonic() - line_t0
212
+ detail = f"{len(episodes)} episodes"
213
+ if ep_errors:
214
+ detail += f", {ep_errors} errors"
215
+ status = "OK" if not ep_errors else "PARTIAL"
216
+ print(f" Time: {format_duration(elapsed)}")
217
+ stats.append((label, status, elapsed, detail))
218
+
219
+ except Exception as exc:
220
+ elapsed = time.monotonic() - line_t0
221
+ print(f" ERROR: {exc}")
222
+ stats.append((label, "ERROR", elapsed, str(exc)))
223
+
224
+ total_elapsed = time.monotonic() - total_t0
225
+
226
+ print(f"\n{'='*60}")
227
+ print(f"Batch complete in {format_duration(total_elapsed)}\n")
228
+
229
+ max_label = max(len(s[0]) for s in stats) if stats else 10
230
+ for label, status, elapsed, detail in stats:
231
+ time_str = format_duration(elapsed) if elapsed > 0 else "-"
232
+ detail_str = f" ({detail})" if detail else ""
233
+ dots = "." * (max_label - len(label) + 3)
234
+ print(f" {label} {dots} {time_str:<10} {status}{detail_str}")
235
+
236
+ counts: dict[str, int] = {}
237
+ for _, status, _, _ in stats:
238
+ counts[status] = counts.get(status, 0) + 1
239
+
240
+ print(f"\nDownloaded: {counts.get('OK', 0)}, Skipped: {counts.get('SKIPPED', 0)}, "
241
+ f"Not found: {counts.get('NOT FOUND', 0)}, Errors: {counts.get('ERROR', 0)}")
242
+ if counts.get("PARTIAL"):
243
+ print(f"Partial: {counts['PARTIAL']}")
244
+ print(f"{'='*60}")
@@ -0,0 +1,77 @@
1
+ """download command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from emby_cli.client import EmbyClient
10
+ from emby_cli.download_ops import do_download, should_skip_item
11
+ from emby_cli.util import build_dest_path
12
+
13
+ def cmd_download(client: EmbyClient, args: argparse.Namespace) -> None:
14
+ output = Path(args.output)
15
+ downloadable = ("Movie", "Episode", "Audio", "Video")
16
+ throttle = getattr(args, "throttle", False)
17
+ method = getattr(args, "method", "download")
18
+
19
+ if args.item_id:
20
+ item_ids = [x.strip() for x in args.item_id.split(",") if x.strip()]
21
+ total = len(item_ids)
22
+ errors = 0
23
+ for idx, iid in enumerate(item_ids, 1):
24
+ prefix = f"[{idx}/{total}]" if total > 1 else ""
25
+ try:
26
+ item = client.get_item_info(iid)
27
+ except Exception as exc:
28
+ print(f"{prefix} ERROR fetching item {iid}: {exc}")
29
+ errors += 1
30
+ continue
31
+ dest = build_dest_path(item, output)
32
+ if should_skip_item(item, dest, method, args.force):
33
+ print(f"{prefix} Skipping (already downloaded): {dest}")
34
+ continue
35
+ print(f"{prefix} Downloading ({method}): {item['Name']} -> {dest}")
36
+ try:
37
+ do_download(client, iid, item, dest, method, throttle)
38
+ except Exception as exc:
39
+ print(f" ERROR: {exc}")
40
+ errors += 1
41
+ if total > 1:
42
+ print(f"\nDone. Items: {total}, Errors: {errors}")
43
+ return
44
+
45
+ if not args.library:
46
+ print("Specify --library or --item-id")
47
+ sys.exit(1)
48
+
49
+ libraries = client.get_libraries()
50
+ lib = next((l for l in libraries if l["Name"].lower() == args.library.lower()), None)
51
+ if not lib:
52
+ print(f"Library '{args.library}' not found")
53
+ sys.exit(1)
54
+
55
+ items = client.get_all_items(parent_id=lib["Id"])
56
+ targets = [i for i in items if i.get("Type") in downloadable]
57
+
58
+ print(f"\nFound {len(targets)} items in '{lib['Name']}'")
59
+ skipped = 0
60
+ errors = 0
61
+
62
+ for idx, item in enumerate(targets, 1):
63
+ dest = build_dest_path(item, output)
64
+ if should_skip_item(item, dest, method, args.force):
65
+ skipped += 1
66
+ continue
67
+
68
+ prefix = f"[{idx}/{len(targets)}]"
69
+ print(f"\n{prefix} {item['Name']}")
70
+ try:
71
+ do_download(client, item["Id"], item, dest, method, throttle)
72
+ except Exception as exc:
73
+ print(f" ERROR: {exc}")
74
+ errors += 1
75
+
76
+ print(f"\nDone. Downloaded: {len(targets) - skipped - errors}, "
77
+ f"Skipped: {skipped}, Errors: {errors}")
@@ -0,0 +1,35 @@
1
+ """list command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from emby_cli.client import EmbyClient
9
+ from emby_cli.util import format_size, item_remote_size
10
+
11
+ def cmd_list(client: EmbyClient, args: argparse.Namespace) -> None:
12
+ libraries = client.get_libraries()
13
+
14
+ if args.library:
15
+ lib = next((l for l in libraries if l["Name"].lower() == args.library.lower()), None)
16
+ if not lib:
17
+ print(f"Library '{args.library}' not found. Available:")
18
+ for l in libraries:
19
+ print(f" - {l['Name']}")
20
+ sys.exit(1)
21
+ items = client.get_all_items(parent_id=lib["Id"])
22
+ print(f"\n{'Name':<60} {'Type':<12} {'Size':>12}")
23
+ print("-" * 86)
24
+ for it in items:
25
+ if it.get("Type") not in ("Movie", "Episode", "Audio", "Video"):
26
+ continue
27
+ size = format_size(item_remote_size(it))
28
+ print(f"{it['Name']:<60} {it['Type']:<12} {size:>12}")
29
+ print(f"\nTotal downloadable items: {len([i for i in items if i.get('Type') in ('Movie','Episode','Audio','Video')])}")
30
+ else:
31
+ print("\nLibraries on server:")
32
+ for lib in libraries:
33
+ count = client.get_items(parent_id=lib["Id"], limit=0)
34
+ print(f" [{lib['Id'][:8]}] {lib['Name']:<30} ({count.get('TotalRecordCount', '?')} items)")
35
+ print()
@@ -0,0 +1,129 @@
1
+ """play command — stream URL in an external player."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import re
8
+ import shlex
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from emby_cli.client import EmbyClient
15
+ from emby_cli.resolve import (
16
+ classify_resolution,
17
+ item_video_width,
18
+ resolve_title_item,
19
+ )
20
+
21
+ _MAC_PLAYER_BINS = (
22
+ "/Applications/VLC.app/Contents/MacOS/VLC",
23
+ "/Applications/IINA.app/Contents/MacOS/IINA",
24
+ "/Applications/mpv.app/Contents/MacOS/mpv",
25
+ )
26
+
27
+
28
+ def find_player(explicit: str | None = None) -> list[str]:
29
+ """Resolve an external player command argv, or raise RuntimeError."""
30
+ if explicit:
31
+ cmd = shlex.split(explicit)
32
+ if not cmd:
33
+ raise RuntimeError("Empty --player / EMBY_PLAYER value")
34
+ binary = cmd[0]
35
+ if os.path.sep in binary or binary.startswith("."):
36
+ if not Path(binary).is_file():
37
+ raise RuntimeError(f"Player not found: {binary}")
38
+ elif shutil.which(binary) is None:
39
+ raise RuntimeError(
40
+ f"Player '{binary}' not found in PATH. "
41
+ "Pass a full path via --player or EMBY_PLAYER."
42
+ )
43
+ return cmd
44
+
45
+ for name in ("vlc", "mpv", "iina"):
46
+ found = shutil.which(name)
47
+ if found:
48
+ return [found]
49
+
50
+ if sys.platform == "darwin":
51
+ for path in _MAC_PLAYER_BINS:
52
+ if Path(path).is_file():
53
+ return [path]
54
+
55
+ raise RuntimeError(
56
+ "No external player found (tried vlc, mpv, iina). "
57
+ "Install one or set --player / EMBY_PLAYER to its path, e.g.\n"
58
+ " --player /Applications/VLC.app/Contents/MacOS/VLC"
59
+ )
60
+
61
+
62
+ def play_url(player_cmd: list[str], url: str, *, wait: bool = False) -> int:
63
+ """Launch *player_cmd* with *url*.
64
+
65
+ By default detaches so closing the player window (common on macOS) does not
66
+ leave this process blocked. Pass wait=True to block until the player exits.
67
+ """
68
+ if wait:
69
+ return subprocess.run([*player_cmd, url], check=False).returncode
70
+
71
+ subprocess.Popen(
72
+ [*player_cmd, url],
73
+ stdin=subprocess.DEVNULL,
74
+ stdout=subprocess.DEVNULL,
75
+ stderr=subprocess.DEVNULL,
76
+ start_new_session=True,
77
+ )
78
+ return 0
79
+
80
+
81
+ def redact_url(url: str) -> str:
82
+ return re.sub(r"(api_key=)[^&]+", r"\1***", url, flags=re.I)
83
+
84
+ def cmd_play(client: EmbyClient, args: argparse.Namespace) -> None:
85
+ """Resolve DirectStreamUrl and open it in an external player."""
86
+ item_id = (args.item_id or "").strip() or None
87
+ query = (args.query or "").strip() or None
88
+
89
+ if not item_id and not query:
90
+ print("Specify --item-id or a search query "
91
+ "(e.g. 'Movie (2010)' or 'Show S01E01')")
92
+ sys.exit(1)
93
+
94
+ if item_id:
95
+ try:
96
+ item = client.get_item_info(item_id)
97
+ except Exception as exc:
98
+ print(f"ERROR fetching item {item_id}: {exc}")
99
+ sys.exit(1)
100
+ else:
101
+ pick_best = bool(getattr(args, "pick_best_item", 0))
102
+ item = resolve_title_item(client, query, pick_best=pick_best)
103
+ if item is None:
104
+ sys.exit(1)
105
+ item_id = item["Id"]
106
+
107
+ try:
108
+ player_cmd = find_player(getattr(args, "player", None))
109
+ except RuntimeError as exc:
110
+ print(f"ERROR: {exc}")
111
+ sys.exit(1)
112
+
113
+ res = classify_resolution(item_video_width(item))
114
+ year = item.get("ProductionYear", "?")
115
+ print(f"Playing: {item.get('Name')} ({year}) [{item.get('Type')}, {res}]")
116
+ try:
117
+ url = client.resolve_direct_stream_url(item_id)
118
+ except Exception as exc:
119
+ print(f"ERROR resolving stream URL: {exc}")
120
+ sys.exit(1)
121
+
122
+ print(f"Stream: {redact_url(url)}")
123
+ print(f"Player: {' '.join(player_cmd)}")
124
+ wait = bool(getattr(args, "wait", False))
125
+ rc = play_url(player_cmd, url, wait=wait)
126
+ if not wait:
127
+ print("Launched (detached). Use --wait to block until the player exits.")
128
+ if rc != 0:
129
+ sys.exit(rc)
@@ -0,0 +1,31 @@
1
+ """search command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from emby_cli.client import EmbyClient
8
+ from emby_cli.util import format_size, item_remote_size
9
+
10
+ def cmd_search(client: EmbyClient, args: argparse.Namespace) -> None:
11
+ uid = client.resolve_user_id()
12
+ params = {
13
+ "SearchTerm": args.query,
14
+ "Limit": 25,
15
+ "Recursive": "true",
16
+ "Fields": "Path,MediaSources,Size",
17
+ "IncludeItemTypes": "Movie,Episode,Audio,Video",
18
+ }
19
+ resp = client._get(f"/Users/{uid}/Items", params=params)
20
+ items = resp.json().get("Items", [])
21
+
22
+ if not items:
23
+ print("No results.")
24
+ return
25
+
26
+ print(f"\n{'ID':<34} {'Name':<50} {'Type':<10} {'Size':>12}")
27
+ print("-" * 108)
28
+ for it in items:
29
+ size = format_size(item_remote_size(it))
30
+ print(f"{it['Id']:<34} {it['Name']:<50} {it['Type']:<10} {size:>12}")
31
+ print()
@@ -0,0 +1,54 @@
1
+ """sync command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from emby_cli.client import EmbyClient
10
+ from emby_cli.download_ops import do_download, should_skip_item
11
+ from emby_cli.util import build_dest_path
12
+
13
+ def cmd_sync(client: EmbyClient, args: argparse.Namespace) -> None:
14
+ """Download all media from all libraries (or a specific one)."""
15
+ output = Path(args.output)
16
+ downloadable = ("Movie", "Episode", "Audio", "Video")
17
+ throttle = getattr(args, "throttle", False)
18
+ method = getattr(args, "method", "download")
19
+ libraries = client.get_libraries()
20
+
21
+ if args.library:
22
+ libraries = [l for l in libraries if l["Name"].lower() == args.library.lower()]
23
+ if not libraries:
24
+ print(f"Library '{args.library}' not found")
25
+ sys.exit(1)
26
+
27
+ total_dl, total_skip, total_err = 0, 0, 0
28
+
29
+ for lib in libraries:
30
+ print(f"\n{'='*60}")
31
+ print(f"Library: {lib['Name']}")
32
+ print(f"{'='*60}")
33
+
34
+ items = client.get_all_items(parent_id=lib["Id"])
35
+ targets = [i for i in items if i.get("Type") in downloadable]
36
+ print(f"Found {len(targets)} items")
37
+
38
+ for idx, item in enumerate(targets, 1):
39
+ dest = build_dest_path(item, output)
40
+ if should_skip_item(item, dest, method, args.force):
41
+ total_skip += 1
42
+ continue
43
+
44
+ prefix = f"[{idx}/{len(targets)}]"
45
+ print(f"\n{prefix} {item['Name']}")
46
+ try:
47
+ do_download(client, item["Id"], item, dest, method, throttle)
48
+ total_dl += 1
49
+ except Exception as exc:
50
+ print(f" ERROR: {exc}")
51
+ total_err += 1
52
+
53
+ print(f"\n{'='*60}")
54
+ print(f"Sync complete. Downloaded: {total_dl}, Skipped: {total_skip}, Errors: {total_err}")
emby_cli/constants.py ADDED
@@ -0,0 +1,9 @@
1
+ """Shared constants for emby-cli."""
2
+
3
+ DEFAULT_CHUNK = 8 * 1024 * 1024 # 8 MiB per read
4
+ TICKS_PER_SECOND = 10_000_000 # Emby RunTimeTicks resolution
5
+ MAX_RETRIES = 5
6
+ RETRY_BACKOFF_BASE = 30 # seconds; doubles each retry: 30, 60, 120, 240, 480
7
+ CLIENT_NAME = "emby-cli"
8
+ DEVICE_NAME = "emby-cli"
9
+ DEFAULT_OUTPUT = "./downloads"
@@ -0,0 +1,47 @@
1
+ """Shared download dispatch and skip/throttle helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from emby_cli.client import EmbyClient
8
+ from emby_cli.util import (
9
+ format_duration,
10
+ format_size,
11
+ item_duration_seconds,
12
+ item_playback_rate,
13
+ should_skip,
14
+ should_skip_hls,
15
+ )
16
+
17
+ def resolve_rate(item: dict, throttle: float) -> float | None:
18
+ if not throttle:
19
+ return None
20
+ rate = item_playback_rate(item)
21
+ if rate:
22
+ rate *= throttle
23
+ duration = item_duration_seconds(item)
24
+ effective = format_duration(duration / throttle if duration else None)
25
+ print(f" Throttle: {format_size(rate)}/s x{throttle:.2g} (ETA: {effective})")
26
+ return rate
27
+
28
+
29
+ def do_download(client: EmbyClient, item_id: str, item: dict, dest: Path,
30
+ method: str, throttle: float) -> None:
31
+ """Dispatch a single item download to the right method."""
32
+ if method == "hls":
33
+ client.download_item_hls(item_id, dest, throttle=throttle)
34
+ return
35
+ rate = resolve_rate(item, throttle)
36
+ if method == "stream":
37
+ client.download_item_stream(item_id, dest, rate_bps=rate)
38
+ else:
39
+ client.download_item(item_id, dest, rate_bps=rate)
40
+
41
+
42
+ def should_skip_item(item: dict, dest: Path, method: str, force: bool) -> bool:
43
+ if force:
44
+ return False
45
+ if method == "hls":
46
+ return should_skip_hls(dest)
47
+ return should_skip(item, dest)