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/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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ emby-cli = emby_cli.cli:main
@@ -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
@@ -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"]
@@ -0,0 +1,4 @@
1
+ from emby_cli.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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