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 +6 -0
- emby_cli/__main__.py +4 -0
- emby_cli/_version.py +24 -0
- emby_cli/cli.py +134 -0
- emby_cli/client.py +499 -0
- emby_cli/commands/__init__.py +1 -0
- emby_cli/commands/batch.py +244 -0
- emby_cli/commands/download.py +77 -0
- emby_cli/commands/list.py +35 -0
- emby_cli/commands/play.py +129 -0
- emby_cli/commands/search.py +31 -0
- emby_cli/commands/sync.py +54 -0
- emby_cli/constants.py +9 -0
- emby_cli/download_ops.py +47 -0
- emby_cli/resolve.py +255 -0
- emby_cli/util.py +117 -0
- emby_cli/version.py +17 -0
- emby_cli-0.1.0.dist-info/METADATA +113 -0
- emby_cli-0.1.0.dist-info/RECORD +39 -0
- emby_cli-0.1.0.dist-info/WHEEL +4 -0
- emby_cli-0.1.0.dist-info/entry_points.txt +2 -0
- emby_cli-0.1.0.dist-info/licenses/LICENSE +20 -0
- src/emby_cli/__init__.py +6 -0
- src/emby_cli/__main__.py +4 -0
- src/emby_cli/_version.py +24 -0
- src/emby_cli/cli.py +134 -0
- src/emby_cli/client.py +499 -0
- src/emby_cli/commands/__init__.py +1 -0
- src/emby_cli/commands/batch.py +244 -0
- src/emby_cli/commands/download.py +77 -0
- src/emby_cli/commands/list.py +35 -0
- src/emby_cli/commands/play.py +129 -0
- src/emby_cli/commands/search.py +31 -0
- src/emby_cli/commands/sync.py +54 -0
- src/emby_cli/constants.py +9 -0
- src/emby_cli/download_ops.py +47 -0
- src/emby_cli/resolve.py +255 -0
- src/emby_cli/util.py +117 -0
- src/emby_cli/version.py +17 -0
|
@@ -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}")
|
|
@@ -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)
|