emby-cli 0.1.0__tar.gz → 0.2.0__tar.gz
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-0.2.0/CHANGELOG.md +11 -0
- {emby_cli-0.1.0 → emby_cli-0.2.0}/PKG-INFO +7 -16
- {emby_cli-0.1.0 → emby_cli-0.2.0}/README.md +6 -15
- {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/_version.py +2 -2
- {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/cli.py +21 -5
- {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/client.py +170 -59
- emby_cli-0.2.0/src/emby_cli/commands/batch.py +158 -0
- emby_cli-0.2.0/src/emby_cli/commands/download.py +71 -0
- {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/commands/list.py +6 -9
- {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/commands/play.py +4 -3
- emby_cli-0.2.0/src/emby_cli/commands/search.py +30 -0
- emby_cli-0.2.0/src/emby_cli/commands/sync.py +46 -0
- {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/constants.py +2 -0
- emby_cli-0.2.0/src/emby_cli/download_ops.py +136 -0
- emby_cli-0.2.0/src/emby_cli/output.py +79 -0
- {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/resolve.py +82 -33
- emby_cli-0.2.0/tests/test_client.py +102 -0
- emby_cli-0.2.0/tests/test_output.py +19 -0
- emby_cli-0.2.0/tests/test_parser.py +67 -0
- emby_cli-0.1.0/CHANGELOG.md +0 -5
- emby_cli-0.1.0/src/emby_cli/commands/batch.py +0 -244
- emby_cli-0.1.0/src/emby_cli/commands/download.py +0 -77
- emby_cli-0.1.0/src/emby_cli/commands/search.py +0 -31
- emby_cli-0.1.0/src/emby_cli/commands/sync.py +0 -54
- emby_cli-0.1.0/src/emby_cli/download_ops.py +0 -47
- emby_cli-0.1.0/tests/test_parser.py +0 -32
- {emby_cli-0.1.0 → emby_cli-0.2.0}/.env.example +0 -0
- {emby_cli-0.1.0 → emby_cli-0.2.0}/.github/workflows/ci.yml +0 -0
- {emby_cli-0.1.0 → emby_cli-0.2.0}/.github/workflows/publish.yml +0 -0
- {emby_cli-0.1.0 → emby_cli-0.2.0}/.gitignore +0 -0
- {emby_cli-0.1.0 → emby_cli-0.2.0}/LICENSE +0 -0
- {emby_cli-0.1.0 → emby_cli-0.2.0}/pyproject.toml +0 -0
- {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/__init__.py +0 -0
- {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/__main__.py +0 -0
- {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/commands/__init__.py +0 -0
- {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/util.py +0 -0
- {emby_cli-0.1.0 → emby_cli-0.2.0}/src/emby_cli/version.py +0 -0
- {emby_cli-0.1.0 → emby_cli-0.2.0}/tests/test_resolve.py +0 -0
- {emby_cli-0.1.0 → emby_cli-0.2.0}/tests/test_util.py +0 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.2.0
|
|
6
|
+
|
|
7
|
+
- API fixes: `get_item_info` Fields, `/Users/Me`, safer 416 resume, `api_key` on play URLs, client version from package, `/emby` base URL normalize, HLS without invalid `copy` codecs, paginated episodes.
|
|
8
|
+
- Shared resolve for `play`/`batch` (strict by default; `--pick-best-item 0|1`).
|
|
9
|
+
- Homogeneous CLI messages (`output.py`); shared library/download loops; non-zero exit on errors / batch not-found.
|
|
10
|
+
- `search`: paginate Emby results (was capped at 25); `--count` / `-n` to limit how many to return (default: all).
|
|
11
|
+
- More tests (`test_client`, `test_output`, parser coverage).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: emby-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: CLI to list, search, play, and download/backup original media from an Emby server via its REST API.
|
|
5
5
|
Project-URL: Homepage, https://github.com/soukron/emby-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/soukron/emby-cli
|
|
@@ -52,13 +52,6 @@ python3 -m venv .venv && source .venv/bin/activate
|
|
|
52
52
|
pip install -e ".[dev]"
|
|
53
53
|
```
|
|
54
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
55
|
## Configuration
|
|
63
56
|
|
|
64
57
|
Set environment variables (or pass CLI flags):
|
|
@@ -79,12 +72,17 @@ See `.env.example`. The app does not load `.env` itself — `source` it or expor
|
|
|
79
72
|
```bash
|
|
80
73
|
emby-cli list
|
|
81
74
|
emby-cli search "Title"
|
|
75
|
+
emby-cli search "Title" --count 50
|
|
82
76
|
emby-cli download -i <itemId> -o ./downloads
|
|
83
77
|
emby-cli sync -l "Movies"
|
|
84
78
|
emby-cli batch -F titles.txt -n
|
|
79
|
+
emby-cli batch -F titles.txt --pick-best-item 1
|
|
85
80
|
emby-cli play "Movie (2010)"
|
|
81
|
+
emby-cli play "Show S01E01" --pick-best-item 1
|
|
86
82
|
```
|
|
87
83
|
|
|
84
|
+
Title resolution (`play` / `batch`) is **strict** by default (year mismatch or multiple matches fail that line). Pass `--pick-best-item 1` to auto-select the best ≤1080p version. In `batch`, a season line like `Show S01` downloads the whole season (with the same pick-best rule per episode version).
|
|
85
|
+
|
|
88
86
|
Download methods (`-m` / `EMBY_METHOD`):
|
|
89
87
|
|
|
90
88
|
- `download` — `GET /Items/{id}/Download`
|
|
@@ -99,14 +97,7 @@ pytest -q
|
|
|
99
97
|
python -m build && twine check dist/*
|
|
100
98
|
```
|
|
101
99
|
|
|
102
|
-
|
|
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
|
-
```
|
|
100
|
+
To publish a release, tag `vX.Y.Z` and push it (or create a GitHub Release). CI builds the package and publishes to PyPI via Trusted Publisher.
|
|
110
101
|
|
|
111
102
|
## License
|
|
112
103
|
|
|
@@ -18,13 +18,6 @@ python3 -m venv .venv && source .venv/bin/activate
|
|
|
18
18
|
pip install -e ".[dev]"
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
In the gmbros.net ops tree (`emby-downloader/`), use the root Makefile instead:
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
cd ~/.local/gmbros.net/emby-downloader
|
|
25
|
-
make install
|
|
26
|
-
```
|
|
27
|
-
|
|
28
21
|
## Configuration
|
|
29
22
|
|
|
30
23
|
Set environment variables (or pass CLI flags):
|
|
@@ -45,12 +38,17 @@ See `.env.example`. The app does not load `.env` itself — `source` it or expor
|
|
|
45
38
|
```bash
|
|
46
39
|
emby-cli list
|
|
47
40
|
emby-cli search "Title"
|
|
41
|
+
emby-cli search "Title" --count 50
|
|
48
42
|
emby-cli download -i <itemId> -o ./downloads
|
|
49
43
|
emby-cli sync -l "Movies"
|
|
50
44
|
emby-cli batch -F titles.txt -n
|
|
45
|
+
emby-cli batch -F titles.txt --pick-best-item 1
|
|
51
46
|
emby-cli play "Movie (2010)"
|
|
47
|
+
emby-cli play "Show S01E01" --pick-best-item 1
|
|
52
48
|
```
|
|
53
49
|
|
|
50
|
+
Title resolution (`play` / `batch`) is **strict** by default (year mismatch or multiple matches fail that line). Pass `--pick-best-item 1` to auto-select the best ≤1080p version. In `batch`, a season line like `Show S01` downloads the whole season (with the same pick-best rule per episode version).
|
|
51
|
+
|
|
54
52
|
Download methods (`-m` / `EMBY_METHOD`):
|
|
55
53
|
|
|
56
54
|
- `download` — `GET /Items/{id}/Download`
|
|
@@ -65,14 +63,7 @@ pytest -q
|
|
|
65
63
|
python -m build && twine check dist/*
|
|
66
64
|
```
|
|
67
65
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
```bash
|
|
71
|
-
cd ~/.local/gmbros.net/emby-downloader
|
|
72
|
-
make install / make test / make build / make check
|
|
73
|
-
make push
|
|
74
|
-
make release VERSION=0.1.0 # tag + gh release → CI publishes to PyPI
|
|
75
|
-
```
|
|
66
|
+
To publish a release, tag `vX.Y.Z` and push it (or create a GitHub Release). CI builds the package and publishes to PyPI via Trusted Publisher.
|
|
76
67
|
|
|
77
68
|
## License
|
|
78
69
|
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -22,6 +22,12 @@ from emby_cli.commands.sync import cmd_sync
|
|
|
22
22
|
from emby_cli.constants import DEFAULT_OUTPUT
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
_FORCE_HELP = (
|
|
26
|
+
"Re-download even if local file matches remote size "
|
|
27
|
+
"(or HLS .mkv + .done marker exists)"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
25
31
|
def build_parser() -> argparse.ArgumentParser:
|
|
26
32
|
env = os.environ.get
|
|
27
33
|
|
|
@@ -44,7 +50,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
44
50
|
dl.add_argument("--item-id", "-i", default=env("EMBY_ITEM_ID"), help="Specific item ID to download (env: EMBY_ITEM_ID)")
|
|
45
51
|
dl.add_argument("--output", "-o", default=env("EMBY_OUTPUT", DEFAULT_OUTPUT),
|
|
46
52
|
help=f"Output directory (env: EMBY_OUTPUT, default: {DEFAULT_OUTPUT})")
|
|
47
|
-
dl.add_argument("--force", "-f", action="store_true", help=
|
|
53
|
+
dl.add_argument("--force", "-f", action="store_true", help=_FORCE_HELP)
|
|
48
54
|
dl.add_argument("--throttle", "-t", type=float, nargs="?", const=1.0, default=0,
|
|
49
55
|
help="Limit speed to playback rate. Optional multiplier: 1=realtime, 1.5=50%% faster (default: off)")
|
|
50
56
|
dl.add_argument("--method", "-m", default=env("EMBY_METHOD", "download"),
|
|
@@ -54,6 +60,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
54
60
|
|
|
55
61
|
sr = sub.add_parser("search", help="Search for items by name")
|
|
56
62
|
sr.add_argument("query", help="Search query")
|
|
63
|
+
sr.add_argument(
|
|
64
|
+
"--count", "-n",
|
|
65
|
+
type=int,
|
|
66
|
+
default=None,
|
|
67
|
+
metavar="N",
|
|
68
|
+
help="Max results to return (default: all, paginated)",
|
|
69
|
+
)
|
|
57
70
|
|
|
58
71
|
pl = sub.add_parser("play", help="Play an item via DirectStreamUrl in an external player")
|
|
59
72
|
pl.add_argument("query", nargs="?",
|
|
@@ -66,14 +79,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
66
79
|
pl.add_argument("--wait", action="store_true",
|
|
67
80
|
help="Block until the player process exits (default: detach and return)")
|
|
68
81
|
pl.add_argument("--pick-best-item", type=int, choices=[0, 1], default=0,
|
|
69
|
-
help="On ambiguous search results: 0=list and
|
|
70
|
-
"1=auto-select best ≤1080p
|
|
82
|
+
help="On ambiguous search results: 0=list and fail (default), "
|
|
83
|
+
"1=auto-select best ≤1080p")
|
|
71
84
|
|
|
72
85
|
sy = sub.add_parser("sync", help="Sync all libraries (or one with --library)")
|
|
73
86
|
sy.add_argument("--library", "-l", default=env("EMBY_LIBRARY"), help="Specific library to sync (env: EMBY_LIBRARY)")
|
|
74
87
|
sy.add_argument("--output", "-o", default=env("EMBY_OUTPUT", DEFAULT_OUTPUT),
|
|
75
88
|
help=f"Output directory (env: EMBY_OUTPUT, default: {DEFAULT_OUTPUT})")
|
|
76
|
-
sy.add_argument("--force", "-f", action="store_true", help=
|
|
89
|
+
sy.add_argument("--force", "-f", action="store_true", help=_FORCE_HELP)
|
|
77
90
|
sy.add_argument("--throttle", "-t", type=float, nargs="?", const=1.0, default=0,
|
|
78
91
|
help="Limit speed to playback rate. Optional multiplier: 1=realtime, 1.5=50%% faster (default: off)")
|
|
79
92
|
sy.add_argument("--method", "-m", default=env("EMBY_METHOD", "download"),
|
|
@@ -86,13 +99,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
86
99
|
ba.add_argument("--dry-run", "-n", action="store_true", help="Search and select only, do not download")
|
|
87
100
|
ba.add_argument("--output", "-o", default=env("EMBY_OUTPUT", DEFAULT_OUTPUT),
|
|
88
101
|
help=f"Output directory (env: EMBY_OUTPUT, default: {DEFAULT_OUTPUT})")
|
|
89
|
-
ba.add_argument("--force", "-f", action="store_true", help=
|
|
102
|
+
ba.add_argument("--force", "-f", action="store_true", help=_FORCE_HELP)
|
|
90
103
|
ba.add_argument("--throttle", "-t", type=float, nargs="?", const=1.0, default=0,
|
|
91
104
|
help="Limit speed to playback rate. Optional multiplier: 1=realtime, 1.5=50%% faster (default: off)")
|
|
92
105
|
ba.add_argument("--method", "-m", default=env("EMBY_METHOD", "download"),
|
|
93
106
|
choices=["download", "stream", "hls"],
|
|
94
107
|
help="Download method: 'download' (API Download), 'stream' (browser-like original.*), "
|
|
95
108
|
"or 'hls' (stream chunks + remux) (env: EMBY_METHOD)")
|
|
109
|
+
ba.add_argument("--pick-best-item", type=int, choices=[0, 1], default=0,
|
|
110
|
+
help="On ambiguous search results: 0=fail the line (default), "
|
|
111
|
+
"1=auto-select best ≤1080p (also per episode version in a season)")
|
|
96
112
|
|
|
97
113
|
return p
|
|
98
114
|
|
|
@@ -6,7 +6,7 @@ import hashlib
|
|
|
6
6
|
import shutil
|
|
7
7
|
import time
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from urllib.parse import urlencode
|
|
9
|
+
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
|
10
10
|
|
|
11
11
|
import m3u8
|
|
12
12
|
import requests
|
|
@@ -16,39 +16,56 @@ from emby_cli.constants import (
|
|
|
16
16
|
CLIENT_NAME,
|
|
17
17
|
DEFAULT_CHUNK,
|
|
18
18
|
DEVICE_NAME,
|
|
19
|
+
ITEM_FIELDS,
|
|
19
20
|
MAX_RETRIES,
|
|
20
21
|
RETRY_BACKOFF_BASE,
|
|
21
22
|
)
|
|
22
23
|
from emby_cli.util import remux_segments
|
|
24
|
+
from emby_cli.version import get_version
|
|
23
25
|
|
|
24
26
|
_DEVICE_ID = hashlib.md5(CLIENT_NAME.encode()).hexdigest()
|
|
25
27
|
|
|
26
28
|
|
|
29
|
+
def _client_version() -> str:
|
|
30
|
+
ver = get_version()
|
|
31
|
+
return ver if ver and ver != "unknown" else "0.0.0"
|
|
32
|
+
|
|
33
|
+
|
|
27
34
|
class EmbyClient:
|
|
28
35
|
def __init__(self, server_url: str, api_key: str | None = None):
|
|
29
|
-
|
|
36
|
+
base = server_url.rstrip("/")
|
|
37
|
+
if base.lower().endswith("/emby"):
|
|
38
|
+
base = base[: -len("/emby")]
|
|
39
|
+
self.server_url = base.rstrip("/")
|
|
30
40
|
self.api_key = api_key
|
|
31
41
|
self.user_id: str | None = None
|
|
32
42
|
self.access_token: str | None = api_key
|
|
33
43
|
self.session = requests.Session()
|
|
44
|
+
ver = _client_version()
|
|
34
45
|
self.session.headers.update({
|
|
35
46
|
"X-Emby-Client": CLIENT_NAME,
|
|
36
47
|
"X-Emby-Device-Name": DEVICE_NAME,
|
|
37
48
|
"X-Emby-Device-Id": _DEVICE_ID,
|
|
38
|
-
"X-Emby-Client-Version":
|
|
49
|
+
"X-Emby-Client-Version": ver,
|
|
39
50
|
})
|
|
40
51
|
|
|
41
52
|
# -- helpers -------------------------------------------------------------
|
|
42
53
|
|
|
43
54
|
def _url(self, path: str) -> str:
|
|
44
|
-
|
|
55
|
+
if path.startswith("http"):
|
|
56
|
+
return path
|
|
57
|
+
if not path.startswith("/"):
|
|
58
|
+
path = "/" + path
|
|
59
|
+
if path.lower().startswith("/emby/"):
|
|
60
|
+
return f"{self.server_url}{path}"
|
|
61
|
+
return f"{self.server_url}/emby{path}"
|
|
45
62
|
|
|
46
63
|
def _auth_header(self) -> dict:
|
|
47
64
|
parts = [
|
|
48
65
|
f'MediaBrowser Client="{CLIENT_NAME}"',
|
|
49
66
|
f'Device="{DEVICE_NAME}"',
|
|
50
67
|
f'DeviceId="{_DEVICE_ID}"',
|
|
51
|
-
'Version="
|
|
68
|
+
f'Version="{_client_version()}"',
|
|
52
69
|
]
|
|
53
70
|
if self.access_token:
|
|
54
71
|
parts.append(f'Token="{self.access_token}"')
|
|
@@ -57,6 +74,7 @@ class EmbyClient:
|
|
|
57
74
|
def _request_with_retry(self, method: str, path: str, **kwargs) -> requests.Response:
|
|
58
75
|
kwargs.setdefault("headers", self._auth_header())
|
|
59
76
|
url = self._url(path)
|
|
77
|
+
resp = None
|
|
60
78
|
for attempt in range(1, MAX_RETRIES + 1):
|
|
61
79
|
try:
|
|
62
80
|
resp = self.session.request(method, url, **kwargs)
|
|
@@ -68,8 +86,8 @@ class EmbyClient:
|
|
|
68
86
|
wait = RETRY_BACKOFF_BASE * (2 ** (attempt - 1))
|
|
69
87
|
print(f" Connection error (attempt {attempt}/{MAX_RETRIES}), retrying in {wait}s: {exc}")
|
|
70
88
|
time.sleep(wait)
|
|
71
|
-
except requests.HTTPError
|
|
72
|
-
if resp.status_code >= 500 and attempt < MAX_RETRIES:
|
|
89
|
+
except requests.HTTPError:
|
|
90
|
+
if resp is not None and resp.status_code >= 500 and attempt < MAX_RETRIES:
|
|
73
91
|
wait = RETRY_BACKOFF_BASE * (2 ** (attempt - 1))
|
|
74
92
|
print(f" Server error {resp.status_code} (attempt {attempt}/{MAX_RETRIES}), retrying in {wait}s")
|
|
75
93
|
time.sleep(wait)
|
|
@@ -83,18 +101,48 @@ class EmbyClient:
|
|
|
83
101
|
def _post(self, path: str, payload: dict | None = None) -> requests.Response:
|
|
84
102
|
return self._request_with_retry("POST", path, json=payload)
|
|
85
103
|
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _ensure_api_key(url: str, token: str | None) -> str:
|
|
106
|
+
"""Append api_key= to *url* when missing (for external players / HLS)."""
|
|
107
|
+
if not token:
|
|
108
|
+
return url
|
|
109
|
+
parsed = urlparse(url)
|
|
110
|
+
qs = parse_qs(parsed.query, keep_blank_values=True)
|
|
111
|
+
keys = {k.lower() for k in qs}
|
|
112
|
+
if "api_key" in keys or "apikey" in keys:
|
|
113
|
+
return url
|
|
114
|
+
q = dict(qs)
|
|
115
|
+
# flatten single-value lists for urlencode
|
|
116
|
+
flat = {k: v[0] if len(v) == 1 else v for k, v in q.items()}
|
|
117
|
+
flat["api_key"] = token
|
|
118
|
+
return urlunparse(parsed._replace(query=urlencode(flat, doseq=True)))
|
|
119
|
+
|
|
86
120
|
# -- auth ----------------------------------------------------------------
|
|
87
121
|
|
|
88
122
|
def authenticate(self, username: str, password: str) -> None:
|
|
89
123
|
data = {"Username": username, "Pw": password}
|
|
90
124
|
resp = self._post("/Users/AuthenticateByName", data)
|
|
91
125
|
body = resp.json()
|
|
92
|
-
|
|
93
|
-
|
|
126
|
+
try:
|
|
127
|
+
self.access_token = body["AccessToken"]
|
|
128
|
+
self.user_id = body["User"]["Id"]
|
|
129
|
+
except (KeyError, TypeError) as exc:
|
|
130
|
+
raise RuntimeError(
|
|
131
|
+
"Authentication response missing AccessToken or User.Id"
|
|
132
|
+
) from exc
|
|
94
133
|
|
|
95
134
|
def resolve_user_id(self) -> str:
|
|
96
135
|
if self.user_id:
|
|
97
136
|
return self.user_id
|
|
137
|
+
# Prefer current user bound to the token / API key
|
|
138
|
+
try:
|
|
139
|
+
me = self._get("/Users/Me").json()
|
|
140
|
+
uid = me.get("Id")
|
|
141
|
+
if uid:
|
|
142
|
+
self.user_id = uid
|
|
143
|
+
return self.user_id
|
|
144
|
+
except requests.HTTPError:
|
|
145
|
+
pass
|
|
98
146
|
users = self._get("/Users").json()
|
|
99
147
|
if not users:
|
|
100
148
|
raise RuntimeError("No users found on server")
|
|
@@ -121,7 +169,7 @@ class EmbyClient:
|
|
|
121
169
|
"StartIndex": start,
|
|
122
170
|
"Limit": limit,
|
|
123
171
|
"Recursive": str(recursive).lower(),
|
|
124
|
-
"Fields":
|
|
172
|
+
"Fields": ITEM_FIELDS,
|
|
125
173
|
"SortBy": "SortName",
|
|
126
174
|
"SortOrder": "Ascending",
|
|
127
175
|
}
|
|
@@ -152,31 +200,81 @@ class EmbyClient:
|
|
|
152
200
|
|
|
153
201
|
def get_item_info(self, item_id: str) -> dict:
|
|
154
202
|
uid = self.resolve_user_id()
|
|
155
|
-
resp = self._get(
|
|
203
|
+
resp = self._get(
|
|
204
|
+
f"/Users/{uid}/Items/{item_id}",
|
|
205
|
+
params={"Fields": ITEM_FIELDS},
|
|
206
|
+
)
|
|
156
207
|
return resp.json()
|
|
157
208
|
|
|
158
|
-
def search_items(
|
|
209
|
+
def search_items(
|
|
210
|
+
self,
|
|
211
|
+
query: str,
|
|
212
|
+
item_types: str = "Movie",
|
|
213
|
+
limit: int | None = 25,
|
|
214
|
+
) -> list[dict]:
|
|
215
|
+
"""Search items by name.
|
|
216
|
+
|
|
217
|
+
*limit* caps how many items to return. ``None`` fetches all pages
|
|
218
|
+
(Emby ``TotalRecordCount``). Internal callers (resolve) keep the
|
|
219
|
+
default of 25; the CLI ``search --count`` overrides this.
|
|
220
|
+
"""
|
|
159
221
|
uid = self.resolve_user_id()
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
222
|
+
items: list[dict] = []
|
|
223
|
+
start = 0
|
|
224
|
+
page_size = 200
|
|
225
|
+
while True:
|
|
226
|
+
if limit is not None:
|
|
227
|
+
remaining = limit - len(items)
|
|
228
|
+
if remaining <= 0:
|
|
229
|
+
break
|
|
230
|
+
batch = min(page_size, remaining)
|
|
231
|
+
else:
|
|
232
|
+
batch = page_size
|
|
233
|
+
params = {
|
|
234
|
+
"SearchTerm": query,
|
|
235
|
+
"StartIndex": start,
|
|
236
|
+
"Limit": batch,
|
|
237
|
+
"Recursive": "true",
|
|
238
|
+
"Fields": ITEM_FIELDS,
|
|
239
|
+
"IncludeItemTypes": item_types,
|
|
240
|
+
}
|
|
241
|
+
resp = self._get(f"/Users/{uid}/Items", params=params)
|
|
242
|
+
page = resp.json()
|
|
243
|
+
chunk = page.get("Items", [])
|
|
244
|
+
items.extend(chunk)
|
|
245
|
+
total = page.get("TotalRecordCount", len(items))
|
|
246
|
+
start += len(chunk)
|
|
247
|
+
if not chunk or start >= total:
|
|
248
|
+
break
|
|
249
|
+
if limit is not None and len(items) >= limit:
|
|
250
|
+
break
|
|
251
|
+
if limit is not None:
|
|
252
|
+
return items[:limit]
|
|
253
|
+
return items
|
|
169
254
|
|
|
170
255
|
def get_show_episodes(self, series_id: str, season: int | None = None) -> list[dict]:
|
|
171
256
|
uid = self.resolve_user_id()
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
257
|
+
items: list[dict] = []
|
|
258
|
+
start = 0
|
|
259
|
+
batch = 200
|
|
260
|
+
while True:
|
|
261
|
+
params: dict = {
|
|
262
|
+
"UserId": uid,
|
|
263
|
+
"Fields": ITEM_FIELDS,
|
|
264
|
+
"StartIndex": start,
|
|
265
|
+
"Limit": batch,
|
|
266
|
+
}
|
|
267
|
+
if season is not None:
|
|
268
|
+
params["Season"] = season
|
|
269
|
+
resp = self._get(f"/Shows/{series_id}/Episodes", params=params)
|
|
270
|
+
page = resp.json()
|
|
271
|
+
chunk = page.get("Items", [])
|
|
272
|
+
items.extend(chunk)
|
|
273
|
+
total = page.get("TotalRecordCount", len(items))
|
|
274
|
+
start += batch
|
|
275
|
+
if start >= total or not chunk:
|
|
276
|
+
break
|
|
277
|
+
return items
|
|
180
278
|
|
|
181
279
|
# -- playback ------------------------------------------------------------
|
|
182
280
|
|
|
@@ -197,8 +295,6 @@ class EmbyClient:
|
|
|
197
295
|
}
|
|
198
296
|
if media_source_id:
|
|
199
297
|
params["MediaSourceId"] = media_source_id
|
|
200
|
-
# Empty DeviceProfile: let the server decide; Emby Web sends one, but
|
|
201
|
-
# DirectStreamUrl is still returned without it for compatible files.
|
|
202
298
|
resp = self._request_with_retry(
|
|
203
299
|
"POST",
|
|
204
300
|
f"/Items/{item_id}/PlaybackInfo",
|
|
@@ -227,9 +323,8 @@ class EmbyClient:
|
|
|
227
323
|
f"(SupportsTranscoding={source.get('SupportsTranscoding')}). "
|
|
228
324
|
"Try --method download or --method hls."
|
|
229
325
|
)
|
|
230
|
-
device_id = _DEVICE_ID
|
|
231
326
|
qs = {
|
|
232
|
-
"DeviceId":
|
|
327
|
+
"DeviceId": _DEVICE_ID,
|
|
233
328
|
"MediaSourceId": source.get("Id") or media_source_id or item_id,
|
|
234
329
|
"PlaySessionId": play_session_id
|
|
235
330
|
or hashlib.md5(f"stream-{item_id}-{time.time()}".encode()).hexdigest(),
|
|
@@ -239,13 +334,16 @@ class EmbyClient:
|
|
|
239
334
|
direct = f"/Videos/{item_id}/original.{container}?{urlencode(qs)}"
|
|
240
335
|
|
|
241
336
|
if direct.startswith("http"):
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
337
|
+
url = direct
|
|
338
|
+
else:
|
|
339
|
+
if not direct.startswith("/"):
|
|
340
|
+
direct = "/" + direct
|
|
341
|
+
if direct.lower().startswith("/emby/"):
|
|
342
|
+
url = f"{self.server_url}{direct}"
|
|
343
|
+
else:
|
|
344
|
+
url = f"{self.server_url}/emby{direct}"
|
|
345
|
+
|
|
346
|
+
return self._ensure_api_key(url, self.access_token)
|
|
249
347
|
|
|
250
348
|
# -- download ------------------------------------------------------------
|
|
251
349
|
|
|
@@ -256,6 +354,7 @@ class EmbyClient:
|
|
|
256
354
|
chunk_size: int = DEFAULT_CHUNK,
|
|
257
355
|
resume: bool = True,
|
|
258
356
|
rate_bps: float | None = None,
|
|
357
|
+
expected_size: int | None = None,
|
|
259
358
|
) -> Path:
|
|
260
359
|
"""Download *url* to *dest_path* with optional resume and rate limit."""
|
|
261
360
|
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -272,9 +371,24 @@ class EmbyClient:
|
|
|
272
371
|
try:
|
|
273
372
|
resp = self.session.get(url, headers=headers, stream=True, timeout=30)
|
|
274
373
|
if resp.status_code == 416:
|
|
374
|
+
# Range not satisfiable: complete only if size matches expectation
|
|
275
375
|
if tmp_path.exists():
|
|
276
|
-
tmp_path.
|
|
277
|
-
|
|
376
|
+
size = tmp_path.stat().st_size
|
|
377
|
+
if expected_size is not None and size == expected_size:
|
|
378
|
+
tmp_path.rename(dest_path)
|
|
379
|
+
return dest_path
|
|
380
|
+
if expected_size is None and size > 0 and dest_path.exists() is False:
|
|
381
|
+
# Without Size, treat equal-to-dest or drop corrupt part
|
|
382
|
+
pass
|
|
383
|
+
print(f" Resume rejected (416); restarting download "
|
|
384
|
+
f"(part={size}, expected={expected_size})")
|
|
385
|
+
tmp_path.unlink(missing_ok=True)
|
|
386
|
+
existing = 0
|
|
387
|
+
headers = dict(self._auth_header())
|
|
388
|
+
continue
|
|
389
|
+
raise RuntimeError(
|
|
390
|
+
f"Server returned 416 with no partial file for {dest_path.name}"
|
|
391
|
+
)
|
|
278
392
|
resp.raise_for_status()
|
|
279
393
|
break
|
|
280
394
|
except (requests.ConnectionError, requests.Timeout) as exc:
|
|
@@ -291,15 +405,15 @@ class EmbyClient:
|
|
|
291
405
|
else:
|
|
292
406
|
raise
|
|
293
407
|
|
|
408
|
+
mode = "ab" if existing and resp.status_code == 206 else "wb"
|
|
409
|
+
if mode == "wb":
|
|
410
|
+
existing = 0
|
|
411
|
+
|
|
294
412
|
total = None
|
|
295
413
|
cl = resp.headers.get("Content-Length")
|
|
296
414
|
if cl:
|
|
297
415
|
total = int(cl) + existing
|
|
298
416
|
|
|
299
|
-
mode = "ab" if existing and resp.status_code == 206 else "wb"
|
|
300
|
-
if mode == "wb":
|
|
301
|
-
existing = 0
|
|
302
|
-
|
|
303
417
|
if rate_bps:
|
|
304
418
|
chunk_size = min(chunk_size, max(16384, int(rate_bps)))
|
|
305
419
|
|
|
@@ -338,6 +452,7 @@ class EmbyClient:
|
|
|
338
452
|
chunk_size: int = DEFAULT_CHUNK,
|
|
339
453
|
resume: bool = True,
|
|
340
454
|
rate_bps: float | None = None,
|
|
455
|
+
expected_size: int | None = None,
|
|
341
456
|
) -> Path:
|
|
342
457
|
"""Download the original file for *item_id* via /Items/{id}/Download."""
|
|
343
458
|
return self._download_from_url(
|
|
@@ -346,6 +461,7 @@ class EmbyClient:
|
|
|
346
461
|
chunk_size=chunk_size,
|
|
347
462
|
resume=resume,
|
|
348
463
|
rate_bps=rate_bps,
|
|
464
|
+
expected_size=expected_size,
|
|
349
465
|
)
|
|
350
466
|
|
|
351
467
|
def download_item_stream(
|
|
@@ -355,6 +471,7 @@ class EmbyClient:
|
|
|
355
471
|
chunk_size: int = DEFAULT_CHUNK,
|
|
356
472
|
resume: bool = True,
|
|
357
473
|
rate_bps: float | None = None,
|
|
474
|
+
expected_size: int | None = None,
|
|
358
475
|
) -> Path:
|
|
359
476
|
"""Download like Emby Web playback: PlaybackInfo + /videos/{id}/original.*."""
|
|
360
477
|
url = self.resolve_direct_stream_url(item_id)
|
|
@@ -364,6 +481,7 @@ class EmbyClient:
|
|
|
364
481
|
chunk_size=chunk_size,
|
|
365
482
|
resume=resume,
|
|
366
483
|
rate_bps=rate_bps,
|
|
484
|
+
expected_size=expected_size,
|
|
367
485
|
)
|
|
368
486
|
|
|
369
487
|
# -- HLS download --------------------------------------------------------
|
|
@@ -406,17 +524,15 @@ class EmbyClient:
|
|
|
406
524
|
raise RuntimeError(f"No media sources for item {item_id}")
|
|
407
525
|
media_source_id = sources[0]["Id"]
|
|
408
526
|
|
|
409
|
-
device_id = _DEVICE_ID
|
|
410
527
|
play_session_id = hashlib.md5(
|
|
411
528
|
f"hls-{item_id}-{time.time()}".encode()
|
|
412
529
|
).hexdigest()
|
|
413
530
|
|
|
531
|
+
# Omit VideoCodec/AudioCodec=copy (not valid Emby codec ids); let server decide.
|
|
414
532
|
hls_params: dict = {
|
|
415
|
-
"DeviceId":
|
|
533
|
+
"DeviceId": _DEVICE_ID,
|
|
416
534
|
"MediaSourceId": media_source_id,
|
|
417
535
|
"PlaySessionId": play_session_id,
|
|
418
|
-
"VideoCodec": "copy",
|
|
419
|
-
"AudioCodec": "copy",
|
|
420
536
|
"SegmentContainer": "ts",
|
|
421
537
|
"BreakOnNonKeyFrames": "false",
|
|
422
538
|
}
|
|
@@ -431,10 +547,9 @@ class EmbyClient:
|
|
|
431
547
|
if not master.playlists:
|
|
432
548
|
raise RuntimeError("No variant streams in master playlist")
|
|
433
549
|
|
|
434
|
-
variant_uri =
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
variant_uri += f"{sep}api_key={self.access_token}"
|
|
550
|
+
variant_uri = self._ensure_api_key(
|
|
551
|
+
master.playlists[0].absolute_uri, self.access_token
|
|
552
|
+
)
|
|
438
553
|
|
|
439
554
|
tmp_dir = dest_path.parent / f".hls-tmp-{item_id}"
|
|
440
555
|
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -460,11 +575,7 @@ class EmbyClient:
|
|
|
460
575
|
continue
|
|
461
576
|
seen_uris.add(seg_uri)
|
|
462
577
|
|
|
463
|
-
dl_uri = seg_uri
|
|
464
|
-
if self.access_token and "api_key" not in dl_uri:
|
|
465
|
-
sep = "&" if "?" in dl_uri else "?"
|
|
466
|
-
dl_uri += f"{sep}api_key={self.access_token}"
|
|
467
|
-
|
|
578
|
+
dl_uri = self._ensure_api_key(seg_uri, self.access_token)
|
|
468
579
|
seg_path = tmp_dir / f"seg_{len(segments):06d}.ts"
|
|
469
580
|
self._download_segment(dl_uri, seg_path)
|
|
470
581
|
segments.append(seg_path)
|