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
emby_cli/resolve.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Title-line parsing, resolution ranking, and item resolution for play/batch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from emby_cli.client import EmbyClient
|
|
8
|
+
from emby_cli.util import format_size, item_remote_size
|
|
9
|
+
|
|
10
|
+
_TITLE_RE = re.compile(
|
|
11
|
+
r"^(.+?)" # title (non-greedy)
|
|
12
|
+
r"(?:\s*\((\d{4})\))?" # optional (YYYY)
|
|
13
|
+
r"(?:\s+S(\d+)" # optional S##
|
|
14
|
+
r"(?:E(\d+))?" # optional E##
|
|
15
|
+
r")?$",
|
|
16
|
+
re.IGNORECASE,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_title_line(line: str) -> tuple[str, int | None, int | None, int | None]:
|
|
21
|
+
"""Parse 'Title', 'Title (2010)', 'Title S01', 'Title (2008) S01E05', etc."""
|
|
22
|
+
line = line.strip()
|
|
23
|
+
m = _TITLE_RE.match(line)
|
|
24
|
+
if m:
|
|
25
|
+
title = m.group(1).strip()
|
|
26
|
+
year = int(m.group(2)) if m.group(2) else None
|
|
27
|
+
season = int(m.group(3)) if m.group(3) else None
|
|
28
|
+
episode = int(m.group(4)) if m.group(4) else None
|
|
29
|
+
return title, season, episode, year
|
|
30
|
+
return line, None, None, None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def item_video_width(item: dict) -> int | None:
|
|
34
|
+
w = item.get("Width")
|
|
35
|
+
if w:
|
|
36
|
+
return w
|
|
37
|
+
streams = item.get("MediaStreams", [])
|
|
38
|
+
if not streams:
|
|
39
|
+
sources = item.get("MediaSources", [])
|
|
40
|
+
if sources:
|
|
41
|
+
streams = sources[0].get("MediaStreams", [])
|
|
42
|
+
for s in streams:
|
|
43
|
+
if s.get("Type") == "Video" and s.get("Width"):
|
|
44
|
+
return s["Width"]
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def classify_resolution(width: int | None) -> str:
|
|
49
|
+
if width is None:
|
|
50
|
+
return "?"
|
|
51
|
+
if width >= 3840:
|
|
52
|
+
return "4K"
|
|
53
|
+
if width >= 1920:
|
|
54
|
+
return "1080p"
|
|
55
|
+
if width >= 1280:
|
|
56
|
+
return "720p"
|
|
57
|
+
return "SD"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def pick_best_item(items: list[dict]) -> dict | None:
|
|
61
|
+
"""Pick the best resolution up to 1080p (skip 4K)."""
|
|
62
|
+
if not items:
|
|
63
|
+
return None
|
|
64
|
+
if len(items) == 1:
|
|
65
|
+
return items[0]
|
|
66
|
+
|
|
67
|
+
classified = [(it, item_video_width(it) or 0) for it in items]
|
|
68
|
+
|
|
69
|
+
# Prefer 1080p
|
|
70
|
+
for it, w in classified:
|
|
71
|
+
if classify_resolution(w) == "1080p":
|
|
72
|
+
return it
|
|
73
|
+
|
|
74
|
+
# Then highest that is NOT 4K (720p, SD, etc.)
|
|
75
|
+
non_4k = [(it, w) for it, w in classified if classify_resolution(w) != "4K"]
|
|
76
|
+
if non_4k:
|
|
77
|
+
non_4k.sort(key=lambda x: x[1], reverse=True)
|
|
78
|
+
return non_4k[0][0]
|
|
79
|
+
|
|
80
|
+
# Only 4K available — take it as last resort
|
|
81
|
+
classified.sort(key=lambda x: x[1], reverse=True)
|
|
82
|
+
return classified[0][0]
|
|
83
|
+
|
|
84
|
+
def item_label(item: dict) -> str:
|
|
85
|
+
"""Human-readable name; episodes include SxxExx prefix."""
|
|
86
|
+
name = item.get("Name") or "?"
|
|
87
|
+
if item.get("Type") == "Episode":
|
|
88
|
+
s, e = item.get("ParentIndexNumber"), item.get("IndexNumber")
|
|
89
|
+
if s is not None and e is not None:
|
|
90
|
+
return f"S{s:02d}E{e:02d} {name}"
|
|
91
|
+
return name
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def print_item_choices(
|
|
95
|
+
items: list[dict],
|
|
96
|
+
*,
|
|
97
|
+
selected: dict | None = None,
|
|
98
|
+
excluded: set[str] | None = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Print a compact, uniform table of items (movies, series, episodes)."""
|
|
101
|
+
if not items:
|
|
102
|
+
return
|
|
103
|
+
excluded = excluded or set()
|
|
104
|
+
id_w = max(len("ID"), max(len(str(it.get("Id", ""))) for it in items))
|
|
105
|
+
name_w = 44
|
|
106
|
+
|
|
107
|
+
header = (
|
|
108
|
+
f"{'ID':<{id_w}} {'Name':<{name_w}} {'Year':<4} {'Type':<8} "
|
|
109
|
+
f"{'Res':>5} {'Size':>9} Note"
|
|
110
|
+
)
|
|
111
|
+
print()
|
|
112
|
+
print(header)
|
|
113
|
+
print("-" * len(header))
|
|
114
|
+
|
|
115
|
+
selected_id = selected.get("Id") if selected else None
|
|
116
|
+
for it in items:
|
|
117
|
+
iid = str(it.get("Id", ""))
|
|
118
|
+
label = item_label(it)
|
|
119
|
+
if len(label) > name_w:
|
|
120
|
+
label = label[: name_w - 1] + "…"
|
|
121
|
+
year = str(it.get("ProductionYear") or "?")
|
|
122
|
+
itype = str(it.get("Type") or "?")
|
|
123
|
+
res = classify_resolution(item_video_width(it))
|
|
124
|
+
size = format_size(item_remote_size(it))
|
|
125
|
+
notes: list[str] = []
|
|
126
|
+
if selected_id is not None and iid == selected_id:
|
|
127
|
+
notes.append("selected")
|
|
128
|
+
if iid in excluded:
|
|
129
|
+
notes.append("year")
|
|
130
|
+
note = ", ".join(notes)
|
|
131
|
+
print(
|
|
132
|
+
f"{iid:<{id_w}} {label:<{name_w}} {year:<4} {itype:<8} "
|
|
133
|
+
f"{res:>5} {size:>9} {note}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def resolve_title_item(
|
|
138
|
+
client: EmbyClient,
|
|
139
|
+
raw_line: str,
|
|
140
|
+
*,
|
|
141
|
+
pick_best: bool = False,
|
|
142
|
+
) -> dict | None:
|
|
143
|
+
"""Resolve a batch-style title line to one playable item.
|
|
144
|
+
|
|
145
|
+
When *pick_best* is True and several candidates remain after title/year
|
|
146
|
+
filters, chooses the best ≤1080p version (same as ``batch``). When False
|
|
147
|
+
(default), any ambiguity prints the table and returns None so the user
|
|
148
|
+
can pass ``--item-id``.
|
|
149
|
+
"""
|
|
150
|
+
title, season, episode, year = parse_title_line(raw_line)
|
|
151
|
+
|
|
152
|
+
def _ambiguous(items: list[dict], *, excluded: set[str] | None = None,
|
|
153
|
+
hint: str = "Use --item-id with an ID from the list above, "
|
|
154
|
+
"or pass --pick-best-item=1 to auto-select.") -> None:
|
|
155
|
+
print_item_choices(items, excluded=excluded)
|
|
156
|
+
print(f"\n{hint}")
|
|
157
|
+
|
|
158
|
+
if season is not None:
|
|
159
|
+
kind = f"S{season:02d}E{episode:02d}" if episode is not None else f"S{season:02d}"
|
|
160
|
+
label = f"{title} ({year}) {kind}" if year else f"{title} {kind}"
|
|
161
|
+
print(f"Searching: \"{label}\" (series)")
|
|
162
|
+
|
|
163
|
+
series_results = client.search_items(title, item_types="Series")
|
|
164
|
+
if not series_results:
|
|
165
|
+
print("No series found.")
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
candidates = series_results
|
|
169
|
+
excluded: set[str] = set()
|
|
170
|
+
if year is not None:
|
|
171
|
+
year_matches = [s for s in series_results if s.get("ProductionYear") == year]
|
|
172
|
+
if year_matches:
|
|
173
|
+
print(f" Year filter: {year} ({len(year_matches)}/{len(series_results)} match)")
|
|
174
|
+
excluded = {s["Id"] for s in series_results if s not in year_matches}
|
|
175
|
+
candidates = year_matches
|
|
176
|
+
else:
|
|
177
|
+
print(f" No series match year {year}.")
|
|
178
|
+
_ambiguous(series_results)
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
if len(candidates) > 1:
|
|
182
|
+
print(" Multiple series matches; pick one with --item-id:")
|
|
183
|
+
_ambiguous(series_results, excluded=excluded)
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
series = candidates[0]
|
|
187
|
+
print(f" Found series: {series['Name']} ({series.get('ProductionYear', '?')})")
|
|
188
|
+
|
|
189
|
+
episodes = client.get_show_episodes(series["Id"], season=season)
|
|
190
|
+
if episode is not None:
|
|
191
|
+
episodes = [e for e in episodes if e.get("IndexNumber") == episode]
|
|
192
|
+
|
|
193
|
+
if not episodes:
|
|
194
|
+
print(f" No episodes found for {kind}.")
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
if episode is None:
|
|
198
|
+
print(f" Season {season:02d} has {len(episodes)} episode(s); "
|
|
199
|
+
"specify SxxExx or --item-id:")
|
|
200
|
+
print_item_choices(episodes)
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
if len(episodes) == 1:
|
|
204
|
+
return episodes[0]
|
|
205
|
+
|
|
206
|
+
if not pick_best:
|
|
207
|
+
print(f" {len(episodes)} versions for {kind}; ambiguous:")
|
|
208
|
+
_ambiguous(episodes)
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
best = pick_best_item(episodes)
|
|
212
|
+
if best is None:
|
|
213
|
+
_ambiguous(episodes)
|
|
214
|
+
return None
|
|
215
|
+
print_item_choices(episodes, selected=best)
|
|
216
|
+
return best
|
|
217
|
+
|
|
218
|
+
# Movie path
|
|
219
|
+
label = f"{title} ({year})" if year else title
|
|
220
|
+
print(f"Searching: \"{label}\" (movie)")
|
|
221
|
+
results = client.search_items(title, item_types="Movie")
|
|
222
|
+
if not results:
|
|
223
|
+
print("No results found.")
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
candidates = results
|
|
227
|
+
excluded: set[str] = set()
|
|
228
|
+
if year is not None:
|
|
229
|
+
year_matches = [r for r in results if r.get("ProductionYear") == year]
|
|
230
|
+
if year_matches:
|
|
231
|
+
print(f" Year filter: {year} ({len(year_matches)}/{len(results)} match)")
|
|
232
|
+
excluded = {r["Id"] for r in results if r not in year_matches}
|
|
233
|
+
candidates = year_matches
|
|
234
|
+
else:
|
|
235
|
+
print(f" No results match year {year}.")
|
|
236
|
+
_ambiguous(results)
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
if len(candidates) == 1:
|
|
240
|
+
best = candidates[0]
|
|
241
|
+
print_item_choices(results, selected=best, excluded=excluded)
|
|
242
|
+
return best
|
|
243
|
+
|
|
244
|
+
if not pick_best:
|
|
245
|
+
print(f" {len(candidates)} matches; ambiguous:")
|
|
246
|
+
_ambiguous(results, excluded=excluded)
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
best = pick_best_item(candidates)
|
|
250
|
+
if best is None:
|
|
251
|
+
_ambiguous(candidates)
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
print_item_choices(results, selected=best, excluded=excluded)
|
|
255
|
+
return best
|
emby_cli/util.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Path, size, skip, and ffmpeg helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from emby_cli.constants import TICKS_PER_SECOND
|
|
9
|
+
|
|
10
|
+
def build_dest_path(item: dict, output_dir: Path) -> Path:
|
|
11
|
+
"""Build a destination path that mirrors the Emby library structure."""
|
|
12
|
+
server_path = item.get("Path", "")
|
|
13
|
+
if server_path:
|
|
14
|
+
# Use the last 2-3 path components (e.g. Movies/Title/file.mkv)
|
|
15
|
+
parts = Path(server_path).parts
|
|
16
|
+
# Take from the library-level folder onward (heuristic: skip leading /downloads, /mnt, etc.)
|
|
17
|
+
relevant = parts[-3:] if len(parts) >= 3 else parts[-2:] if len(parts) >= 2 else parts
|
|
18
|
+
return output_dir / Path(*relevant)
|
|
19
|
+
|
|
20
|
+
name = item.get("Name", item["Id"])
|
|
21
|
+
ext = ".mkv"
|
|
22
|
+
sources = item.get("MediaSources", [])
|
|
23
|
+
if sources and sources[0].get("Container"):
|
|
24
|
+
ext = "." + sources[0]["Container"]
|
|
25
|
+
return output_dir / f"{name}{ext}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def item_duration_seconds(item: dict) -> float | None:
|
|
29
|
+
ticks = item.get("RunTimeTicks")
|
|
30
|
+
if ticks:
|
|
31
|
+
return ticks / TICKS_PER_SECOND
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def item_playback_rate(item: dict) -> float | None:
|
|
36
|
+
"""Bytes per second that matches real-time playback speed."""
|
|
37
|
+
size = item_remote_size(item)
|
|
38
|
+
duration = item_duration_seconds(item)
|
|
39
|
+
if size and duration and duration > 0:
|
|
40
|
+
return size / duration
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def format_duration(seconds: float | None) -> str:
|
|
45
|
+
if seconds is None:
|
|
46
|
+
return "?"
|
|
47
|
+
h, rem = divmod(int(seconds), 3600)
|
|
48
|
+
m, s = divmod(rem, 60)
|
|
49
|
+
if h:
|
|
50
|
+
return f"{h}h{m:02d}m"
|
|
51
|
+
return f"{m}m{s:02d}s"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def item_remote_size(item: dict) -> int | None:
|
|
55
|
+
sources = item.get("MediaSources", [])
|
|
56
|
+
if sources:
|
|
57
|
+
return sources[0].get("Size")
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def format_size(n: int | None) -> str:
|
|
62
|
+
if n is None:
|
|
63
|
+
return "?"
|
|
64
|
+
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
65
|
+
if abs(n) < 1024:
|
|
66
|
+
return f"{n:.1f} {unit}"
|
|
67
|
+
n /= 1024 # type: ignore[assignment]
|
|
68
|
+
return f"{n:.1f} PiB"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def should_skip(item: dict, dest: Path) -> bool:
|
|
72
|
+
"""Return True when the local file already matches the remote size."""
|
|
73
|
+
if not dest.exists():
|
|
74
|
+
return False
|
|
75
|
+
remote_size = item_remote_size(item)
|
|
76
|
+
if remote_size is None:
|
|
77
|
+
return False
|
|
78
|
+
return dest.stat().st_size == remote_size
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def should_skip_hls(dest: Path) -> bool:
|
|
82
|
+
"""Skip only if the .mkv AND its .done marker both exist."""
|
|
83
|
+
mkv = dest.with_suffix(".mkv")
|
|
84
|
+
done = Path(str(mkv) + ".done")
|
|
85
|
+
return mkv.exists() and done.exists()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def ffmpeg_exe() -> str:
|
|
89
|
+
"""Return the bundled static-ffmpeg binary (never the system PATH)."""
|
|
90
|
+
try:
|
|
91
|
+
from static_ffmpeg.run import get_or_fetch_platform_executables_else_raise
|
|
92
|
+
except ImportError as exc:
|
|
93
|
+
raise RuntimeError(
|
|
94
|
+
"static-ffmpeg is required for HLS remux. "
|
|
95
|
+
"Install it with: pip install static-ffmpeg"
|
|
96
|
+
) from exc
|
|
97
|
+
ffmpeg_path, _ = get_or_fetch_platform_executables_else_raise()
|
|
98
|
+
return ffmpeg_path
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def remux_segments(tmp_dir: Path, segments: list[Path], dest_path: Path) -> None:
|
|
102
|
+
"""Concatenate .ts segments into a single .mkv using bundled ffmpeg -c copy."""
|
|
103
|
+
concat_file = tmp_dir / "concat.txt"
|
|
104
|
+
with open(concat_file, "w") as f:
|
|
105
|
+
for seg in segments:
|
|
106
|
+
f.write(f"file '{seg.resolve()}'\n")
|
|
107
|
+
|
|
108
|
+
cmd = [
|
|
109
|
+
ffmpeg_exe(), "-y", "-hide_banner", "-loglevel", "warning",
|
|
110
|
+
"-f", "concat", "-safe", "0",
|
|
111
|
+
"-i", str(concat_file),
|
|
112
|
+
"-c", "copy",
|
|
113
|
+
str(dest_path),
|
|
114
|
+
]
|
|
115
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
116
|
+
if result.returncode != 0:
|
|
117
|
+
raise RuntimeError(f"ffmpeg remux failed:\n{result.stderr}")
|
emby_cli/version.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_version() -> str:
|
|
5
|
+
try:
|
|
6
|
+
from emby_cli._version import __version__
|
|
7
|
+
|
|
8
|
+
return __version__
|
|
9
|
+
except ImportError:
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from importlib.metadata import version
|
|
14
|
+
|
|
15
|
+
return version("emby-cli")
|
|
16
|
+
except Exception:
|
|
17
|
+
return "unknown"
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: emby-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI to list, search, play, and download/backup original media from an Emby server via its REST API.
|
|
5
|
+
Project-URL: Homepage, https://github.com/soukron/emby-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/soukron/emby-cli
|
|
7
|
+
Project-URL: Issues, https://github.com/soukron/emby-cli/issues
|
|
8
|
+
Author-email: Sergio Garcia <soukron@gmbros.net>
|
|
9
|
+
Maintainer-email: Sergio Garcia <soukron@gmbros.net>
|
|
10
|
+
License-Expression: CC-BY-NC-4.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: backup,cli,download,emby,media
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: License :: Free for non-commercial use
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Topic :: Multimedia :: Video
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Requires-Dist: m3u8>=4.0
|
|
26
|
+
Requires-Dist: requests>=2.31
|
|
27
|
+
Requires-Dist: static-ffmpeg>=3.0
|
|
28
|
+
Requires-Dist: tqdm>=4.66
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: build>=1.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# emby-cli
|
|
36
|
+
|
|
37
|
+
[PyPI](https://pypi.org/project/emby-cli/)
|
|
38
|
+
[License: CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/)
|
|
39
|
+
|
|
40
|
+
CLI to **list, search, play, and download/backup** original media files from an [Emby](https://emby.media/) server via its REST API. Only HTTP access is required (default port 8096) — no SSH or rsync.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install emby-cli
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Development (from a clone of this repo):
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
52
|
+
pip install -e ".[dev]"
|
|
53
|
+
```
|
|
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
|
+
## Configuration
|
|
63
|
+
|
|
64
|
+
Set environment variables (or pass CLI flags):
|
|
65
|
+
|
|
66
|
+
| Variable | Description |
|
|
67
|
+
|----------|-------------|
|
|
68
|
+
| `EMBY_SERVER` | Server URL (required) |
|
|
69
|
+
| `EMBY_API_KEY` | API key (or use username/password) |
|
|
70
|
+
| `EMBY_USERNAME` / `EMBY_PASSWORD` | Name/password auth |
|
|
71
|
+
| `EMBY_OUTPUT` | Download directory (default `./downloads`) |
|
|
72
|
+
| `EMBY_METHOD` | `download`, `stream`, or `hls` |
|
|
73
|
+
| `EMBY_PLAYER` | External player for `play` |
|
|
74
|
+
|
|
75
|
+
See `.env.example`. The app does not load `.env` itself — `source` it or export vars.
|
|
76
|
+
|
|
77
|
+
## Usage
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
emby-cli list
|
|
81
|
+
emby-cli search "Title"
|
|
82
|
+
emby-cli download -i <itemId> -o ./downloads
|
|
83
|
+
emby-cli sync -l "Movies"
|
|
84
|
+
emby-cli batch -F titles.txt -n
|
|
85
|
+
emby-cli play "Movie (2010)"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Download methods (`-m` / `EMBY_METHOD`):
|
|
89
|
+
|
|
90
|
+
- `download` — `GET /Items/{id}/Download`
|
|
91
|
+
- `stream` — DirectStreamUrl / `original.*` (Emby Web style)
|
|
92
|
+
- `hls` — HLS segments remuxed to `.mkv` (needs `static-ffmpeg`)
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
pip install -e ".[dev]"
|
|
98
|
+
pytest -q
|
|
99
|
+
python -m build && twine check dist/*
|
|
100
|
+
```
|
|
101
|
+
|
|
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
|
+
```
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
[CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
src/emby_cli/__init__.py,sha256=-uPhkssq9ItB8QL5Wylev3g91Oxry2WVa1BnCJ6uzJ0,186
|
|
2
|
+
src/emby_cli/__main__.py,sha256=-FUM3SSVm-uufrAezbyW2jy7DZTenot0u5dqTo5hNwA,69
|
|
3
|
+
src/emby_cli/_version.py,sha256=n_5vdJsPNu7wZ57LGuRL585uvll-hiuvZUBWzdG0RQU,520
|
|
4
|
+
src/emby_cli/cli.py,sha256=5KvYNjemPHemHnKTnFZ9ff06tGXALd4CcC1KEh8BZ70,6885
|
|
5
|
+
src/emby_cli/client.py,sha256=yWI8UAwTNT9fPrVQx9b7J9YpKZYC9FJiNWosZWmQYsQ,18664
|
|
6
|
+
src/emby_cli/constants.py,sha256=Bd_b4QgxtSvRc1XuaPyXV8BIawDhywEkNAczchrHuDw,325
|
|
7
|
+
src/emby_cli/download_ops.py,sha256=So_i-oejlDfqKBAjM5raPyqRB8RPR4Bdcy6btX8ohrQ,1442
|
|
8
|
+
src/emby_cli/resolve.py,sha256=9OZzuK85XWTc4EXZ9mLztWA05rLhQL3fiMmJIC1NmvY,8406
|
|
9
|
+
src/emby_cli/util.py,sha256=1kKLfVi-TIkSYS-dxWo7K91kxAVz4t-LyB-ATdfHEDQ,3760
|
|
10
|
+
src/emby_cli/version.py,sha256=FTuuw2a_2bAbBZqXmSjPtiCyDe94eMQV-6gR5r1R5oU,327
|
|
11
|
+
src/emby_cli/commands/__init__.py,sha256=TOMJAh6GCkJ3gAlu1iZo1eKtdBP7_MzCeUrxozBCW2Y,28
|
|
12
|
+
src/emby_cli/commands/batch.py,sha256=9bksmor_kY18WnVcLMX1Ti5kIb8TWCj_YWGsEULwxJE,10549
|
|
13
|
+
src/emby_cli/commands/download.py,sha256=SMqO8pA2jAnF389iItuTAXJhVhtU8I93aNbLgN34mv8,2700
|
|
14
|
+
src/emby_cli/commands/list.py,sha256=g7sjQJDtuY1CHAB_uGbEDr_RBUXId3o_qWH2y6jTI3c,1375
|
|
15
|
+
src/emby_cli/commands/play.py,sha256=Fs0i2GkBLtqlMuD3-vfOWIKNuoe6nOp1vu06-K_JpvI,4019
|
|
16
|
+
src/emby_cli/commands/search.py,sha256=eyJvyFwUNR1dbTXHRB--ELQvWVpH4y1x3tV_JQWgHMw,896
|
|
17
|
+
src/emby_cli/commands/sync.py,sha256=vA58xepHbS_4HMFsWW4lfz3qy_3l_ZZe3WkkkE9LHe0,1852
|
|
18
|
+
emby_cli/__init__.py,sha256=-uPhkssq9ItB8QL5Wylev3g91Oxry2WVa1BnCJ6uzJ0,186
|
|
19
|
+
emby_cli/__main__.py,sha256=-FUM3SSVm-uufrAezbyW2jy7DZTenot0u5dqTo5hNwA,69
|
|
20
|
+
emby_cli/_version.py,sha256=n_5vdJsPNu7wZ57LGuRL585uvll-hiuvZUBWzdG0RQU,520
|
|
21
|
+
emby_cli/cli.py,sha256=5KvYNjemPHemHnKTnFZ9ff06tGXALd4CcC1KEh8BZ70,6885
|
|
22
|
+
emby_cli/client.py,sha256=yWI8UAwTNT9fPrVQx9b7J9YpKZYC9FJiNWosZWmQYsQ,18664
|
|
23
|
+
emby_cli/constants.py,sha256=Bd_b4QgxtSvRc1XuaPyXV8BIawDhywEkNAczchrHuDw,325
|
|
24
|
+
emby_cli/download_ops.py,sha256=So_i-oejlDfqKBAjM5raPyqRB8RPR4Bdcy6btX8ohrQ,1442
|
|
25
|
+
emby_cli/resolve.py,sha256=9OZzuK85XWTc4EXZ9mLztWA05rLhQL3fiMmJIC1NmvY,8406
|
|
26
|
+
emby_cli/util.py,sha256=1kKLfVi-TIkSYS-dxWo7K91kxAVz4t-LyB-ATdfHEDQ,3760
|
|
27
|
+
emby_cli/version.py,sha256=FTuuw2a_2bAbBZqXmSjPtiCyDe94eMQV-6gR5r1R5oU,327
|
|
28
|
+
emby_cli/commands/__init__.py,sha256=TOMJAh6GCkJ3gAlu1iZo1eKtdBP7_MzCeUrxozBCW2Y,28
|
|
29
|
+
emby_cli/commands/batch.py,sha256=9bksmor_kY18WnVcLMX1Ti5kIb8TWCj_YWGsEULwxJE,10549
|
|
30
|
+
emby_cli/commands/download.py,sha256=SMqO8pA2jAnF389iItuTAXJhVhtU8I93aNbLgN34mv8,2700
|
|
31
|
+
emby_cli/commands/list.py,sha256=g7sjQJDtuY1CHAB_uGbEDr_RBUXId3o_qWH2y6jTI3c,1375
|
|
32
|
+
emby_cli/commands/play.py,sha256=Fs0i2GkBLtqlMuD3-vfOWIKNuoe6nOp1vu06-K_JpvI,4019
|
|
33
|
+
emby_cli/commands/search.py,sha256=eyJvyFwUNR1dbTXHRB--ELQvWVpH4y1x3tV_JQWgHMw,896
|
|
34
|
+
emby_cli/commands/sync.py,sha256=vA58xepHbS_4HMFsWW4lfz3qy_3l_ZZe3WkkkE9LHe0,1852
|
|
35
|
+
emby_cli-0.1.0.dist-info/METADATA,sha256=azP6xcnde6BmA1ET-s4GnDOVpEIr6xA27fQoSGxqtVM,3400
|
|
36
|
+
emby_cli-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
37
|
+
emby_cli-0.1.0.dist-info/entry_points.txt,sha256=bo4U_yomvNRh3GOmtJn1JOcFJB6S15TMLKZMRIv-Yos,47
|
|
38
|
+
emby_cli-0.1.0.dist-info/licenses/LICENSE,sha256=AUjowifY4qllx1vIGnPthpe4JEPpjJo77U21Ycd-hww,833
|
|
39
|
+
emby_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Creative Commons Attribution-NonCommercial 4.0 International
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sergio Garcia <soukron@gmbros.net>
|
|
4
|
+
|
|
5
|
+
This work is licensed under the Creative Commons Attribution-NonCommercial
|
|
6
|
+
4.0 International License.
|
|
7
|
+
|
|
8
|
+
You are free to:
|
|
9
|
+
- Share — copy and redistribute the material in any medium or format
|
|
10
|
+
- Adapt — remix, transform, and build upon the material
|
|
11
|
+
|
|
12
|
+
Under the following terms:
|
|
13
|
+
- Attribution — You must give appropriate credit, provide a link to the
|
|
14
|
+
license, and indicate if changes were made.
|
|
15
|
+
- NonCommercial — You may not use the material for commercial purposes.
|
|
16
|
+
|
|
17
|
+
No additional restrictions — You may not apply legal terms or technological
|
|
18
|
+
measures that legally restrict others from doing anything the license permits.
|
|
19
|
+
|
|
20
|
+
Full license text: https://creativecommons.org/licenses/by-nc/4.0/legalcode
|
src/emby_cli/__init__.py
ADDED
src/emby_cli/__main__.py
ADDED
src/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
|