emby-cli 0.2.0__tar.gz → 0.2.2__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.2/CHANGELOG.md +22 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/PKG-INFO +8 -4
- {emby_cli-0.2.0 → emby_cli-0.2.2}/README.md +7 -3
- {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/_version.py +2 -2
- {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/cli.py +23 -6
- {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/client.py +178 -17
- {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/commands/batch.py +1 -1
- emby_cli-0.2.2/src/emby_cli/commands/info.py +125 -0
- emby_cli-0.2.2/src/emby_cli/commands/list.py +50 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/commands/play.py +1 -1
- emby_cli-0.2.2/src/emby_cli/commands/version.py +35 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/constants.py +3 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/resolve.py +30 -1
- emby_cli-0.2.2/tests/test_info.py +105 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/tests/test_parser.py +18 -5
- emby_cli-0.2.2/tests/test_resolve_table.py +42 -0
- emby_cli-0.2.2/tests/test_version.py +127 -0
- emby_cli-0.2.0/CHANGELOG.md +0 -11
- emby_cli-0.2.0/src/emby_cli/commands/list.py +0 -32
- {emby_cli-0.2.0 → emby_cli-0.2.2}/.env.example +0 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/.github/workflows/ci.yml +0 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/.github/workflows/publish.yml +0 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/.gitignore +0 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/LICENSE +0 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/pyproject.toml +0 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/__init__.py +0 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/__main__.py +0 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/commands/__init__.py +0 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/commands/download.py +0 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/commands/search.py +0 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/commands/sync.py +0 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/download_ops.py +0 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/output.py +0 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/util.py +0 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/version.py +0 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/tests/test_client.py +0 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/tests/test_output.py +0 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/tests/test_resolve.py +0 -0
- {emby_cli-0.2.0 → emby_cli-0.2.2}/tests/test_util.py +0 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.2.2
|
|
6
|
+
|
|
7
|
+
- `version`: show emby-cli version; with valid server credentials also show Emby `/System/Info` version.
|
|
8
|
+
- `info`: session summary (user, url, server, version, os, id) plus library names and item counts from `GET /Items/Counts` (replaces draft `whoami`).
|
|
9
|
+
- `--pick-best-item` is a flag (presence enables auto-select); no longer takes `0`/`1`.
|
|
10
|
+
- `info` / `version` probe the server once (no retries); on failure show configured user/URL and mark server name/version as not validated.
|
|
11
|
+
- Note: `/Items/Counts` may count multiple versions of the same title higher than UI “items” views.
|
|
12
|
+
- `probe_session`: fall back to `/System/Info/Public` when full `/System/Info` is forbidden (non-admin users); still proceed with Views/Counts.
|
|
13
|
+
- Avoid broken `/Users/Me` on some Emby servers (HTTP 500 Guid format): use `User` from authenticate or `/Users/{id}`.
|
|
14
|
+
- `list`: library table is ID/Name/Type/Items (no Year/Res); gather all counts before printing; `list -l` keeps media table like `search`.
|
|
15
|
+
|
|
16
|
+
## 0.2.0
|
|
17
|
+
|
|
18
|
+
- 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.
|
|
19
|
+
- Shared resolve for `play`/`batch` (strict by default; `--pick-best-item` flag).
|
|
20
|
+
- Homogeneous CLI messages (`output.py`); shared library/download loops; non-zero exit on errors / batch not-found.
|
|
21
|
+
- `search`: paginate Emby results (was capped at 25); `--count` / `-n` to limit how many to return (default: all).
|
|
22
|
+
- 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.2.
|
|
3
|
+
Version: 0.2.2
|
|
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
|
|
@@ -70,18 +70,22 @@ See `.env.example`. The app does not load `.env` itself — `source` it or expor
|
|
|
70
70
|
## Usage
|
|
71
71
|
|
|
72
72
|
```bash
|
|
73
|
+
emby-cli version
|
|
74
|
+
emby-cli info
|
|
73
75
|
emby-cli list
|
|
74
76
|
emby-cli search "Title"
|
|
75
77
|
emby-cli search "Title" --count 50
|
|
76
78
|
emby-cli download -i <itemId> -o ./downloads
|
|
77
79
|
emby-cli sync -l "Movies"
|
|
78
80
|
emby-cli batch -F titles.txt -n
|
|
79
|
-
emby-cli batch -F titles.txt --pick-best-item
|
|
81
|
+
emby-cli batch -F titles.txt --pick-best-item
|
|
80
82
|
emby-cli play "Movie (2010)"
|
|
81
|
-
emby-cli play "Show S01E01" --pick-best-item
|
|
83
|
+
emby-cli play "Show S01E01" --pick-best-item
|
|
82
84
|
```
|
|
83
85
|
|
|
84
|
-
|
|
86
|
+
`info` reports libraries and totals from `GET /Items/Counts` (totals may include multiple versions of the same title).
|
|
87
|
+
|
|
88
|
+
Title resolution (`play` / `batch`) is **strict** by default (year mismatch or multiple matches fail that line). Pass `--pick-best-item` 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
89
|
|
|
86
90
|
Download methods (`-m` / `EMBY_METHOD`):
|
|
87
91
|
|
|
@@ -36,18 +36,22 @@ See `.env.example`. The app does not load `.env` itself — `source` it or expor
|
|
|
36
36
|
## Usage
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
|
+
emby-cli version
|
|
40
|
+
emby-cli info
|
|
39
41
|
emby-cli list
|
|
40
42
|
emby-cli search "Title"
|
|
41
43
|
emby-cli search "Title" --count 50
|
|
42
44
|
emby-cli download -i <itemId> -o ./downloads
|
|
43
45
|
emby-cli sync -l "Movies"
|
|
44
46
|
emby-cli batch -F titles.txt -n
|
|
45
|
-
emby-cli batch -F titles.txt --pick-best-item
|
|
47
|
+
emby-cli batch -F titles.txt --pick-best-item
|
|
46
48
|
emby-cli play "Movie (2010)"
|
|
47
|
-
emby-cli play "Show S01E01" --pick-best-item
|
|
49
|
+
emby-cli play "Show S01E01" --pick-best-item
|
|
48
50
|
```
|
|
49
51
|
|
|
50
|
-
|
|
52
|
+
`info` reports libraries and totals from `GET /Items/Counts` (totals may include multiple versions of the same title).
|
|
53
|
+
|
|
54
|
+
Title resolution (`play` / `batch`) is **strict** by default (year mismatch or multiple matches fail that line). Pass `--pick-best-item` 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
55
|
|
|
52
56
|
Download methods (`-m` / `EMBY_METHOD`):
|
|
53
57
|
|
|
@@ -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.2.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2,
|
|
21
|
+
__version__ = version = '0.2.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 2)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -15,10 +15,12 @@ import requests
|
|
|
15
15
|
from emby_cli.client import EmbyClient
|
|
16
16
|
from emby_cli.commands.batch import cmd_batch
|
|
17
17
|
from emby_cli.commands.download import cmd_download
|
|
18
|
+
from emby_cli.commands.info import cmd_info
|
|
18
19
|
from emby_cli.commands.list import cmd_list
|
|
19
20
|
from emby_cli.commands.play import cmd_play
|
|
20
21
|
from emby_cli.commands.search import cmd_search
|
|
21
22
|
from emby_cli.commands.sync import cmd_sync
|
|
23
|
+
from emby_cli.commands.version import cmd_version
|
|
22
24
|
from emby_cli.constants import DEFAULT_OUTPUT
|
|
23
25
|
|
|
24
26
|
|
|
@@ -78,9 +80,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
78
80
|
"/Applications/VLC.app/Contents/MacOS/VLC")
|
|
79
81
|
pl.add_argument("--wait", action="store_true",
|
|
80
82
|
help="Block until the player process exits (default: detach and return)")
|
|
81
|
-
pl.add_argument("--pick-best-item",
|
|
82
|
-
help="On ambiguous search results
|
|
83
|
-
"
|
|
83
|
+
pl.add_argument("--pick-best-item", action="store_true",
|
|
84
|
+
help="On ambiguous search results, auto-select best ≤1080p "
|
|
85
|
+
"(default: list matches and fail)")
|
|
84
86
|
|
|
85
87
|
sy = sub.add_parser("sync", help="Sync all libraries (or one with --library)")
|
|
86
88
|
sy.add_argument("--library", "-l", default=env("EMBY_LIBRARY"), help="Specific library to sync (env: EMBY_LIBRARY)")
|
|
@@ -106,9 +108,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
106
108
|
choices=["download", "stream", "hls"],
|
|
107
109
|
help="Download method: 'download' (API Download), 'stream' (browser-like original.*), "
|
|
108
110
|
"or 'hls' (stream chunks + remux) (env: EMBY_METHOD)")
|
|
109
|
-
ba.add_argument("--pick-best-item",
|
|
110
|
-
help="On ambiguous search results
|
|
111
|
-
"
|
|
111
|
+
ba.add_argument("--pick-best-item", action="store_true",
|
|
112
|
+
help="On ambiguous search results, auto-select best ≤1080p "
|
|
113
|
+
"(also per episode version in a season; default: fail the line)")
|
|
114
|
+
|
|
115
|
+
sub.add_parser("version", help="Show emby-cli version (and Emby server version if credentials work)")
|
|
116
|
+
sub.add_parser(
|
|
117
|
+
"info",
|
|
118
|
+
help="Show session user, server details, libraries, and item counts "
|
|
119
|
+
"(via /Items/Counts; versions may inflate totals)",
|
|
120
|
+
)
|
|
112
121
|
|
|
113
122
|
return p
|
|
114
123
|
|
|
@@ -117,6 +126,14 @@ def main() -> None:
|
|
|
117
126
|
parser = build_parser()
|
|
118
127
|
args = parser.parse_args()
|
|
119
128
|
|
|
129
|
+
if args.command == "version":
|
|
130
|
+
cmd_version(args)
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
if args.command == "info":
|
|
134
|
+
cmd_info(args)
|
|
135
|
+
return
|
|
136
|
+
|
|
120
137
|
if not args.server:
|
|
121
138
|
parser.error("Provide --server or set EMBY_SERVER")
|
|
122
139
|
|
|
@@ -16,6 +16,8 @@ from emby_cli.constants import (
|
|
|
16
16
|
CLIENT_NAME,
|
|
17
17
|
DEFAULT_CHUNK,
|
|
18
18
|
DEVICE_NAME,
|
|
19
|
+
INFO_RETRIES,
|
|
20
|
+
INFO_TIMEOUT,
|
|
19
21
|
ITEM_FIELDS,
|
|
20
22
|
MAX_RETRIES,
|
|
21
23
|
RETRY_BACKOFF_BASE,
|
|
@@ -71,35 +73,57 @@ class EmbyClient:
|
|
|
71
73
|
parts.append(f'Token="{self.access_token}"')
|
|
72
74
|
return {"X-Emby-Authorization": ", ".join(parts)}
|
|
73
75
|
|
|
74
|
-
def _request_with_retry(
|
|
76
|
+
def _request_with_retry(
|
|
77
|
+
self,
|
|
78
|
+
method: str,
|
|
79
|
+
path: str,
|
|
80
|
+
*,
|
|
81
|
+
retries: int | None = None,
|
|
82
|
+
**kwargs,
|
|
83
|
+
) -> requests.Response:
|
|
75
84
|
kwargs.setdefault("headers", self._auth_header())
|
|
76
85
|
url = self._url(path)
|
|
86
|
+
max_attempts = MAX_RETRIES if retries is None else max(1, retries)
|
|
77
87
|
resp = None
|
|
78
|
-
for attempt in range(1,
|
|
88
|
+
for attempt in range(1, max_attempts + 1):
|
|
79
89
|
try:
|
|
80
90
|
resp = self.session.request(method, url, **kwargs)
|
|
81
91
|
resp.raise_for_status()
|
|
82
92
|
return resp
|
|
83
93
|
except (requests.ConnectionError, requests.Timeout) as exc:
|
|
84
|
-
if attempt ==
|
|
94
|
+
if attempt == max_attempts:
|
|
85
95
|
raise
|
|
86
96
|
wait = RETRY_BACKOFF_BASE * (2 ** (attempt - 1))
|
|
87
|
-
print(f" Connection error (attempt {attempt}/{
|
|
97
|
+
print(f" Connection error (attempt {attempt}/{max_attempts}), retrying in {wait}s: {exc}")
|
|
88
98
|
time.sleep(wait)
|
|
89
99
|
except requests.HTTPError:
|
|
90
|
-
if resp is not None and resp.status_code >= 500 and attempt <
|
|
100
|
+
if resp is not None and resp.status_code >= 500 and attempt < max_attempts:
|
|
91
101
|
wait = RETRY_BACKOFF_BASE * (2 ** (attempt - 1))
|
|
92
|
-
print(f" Server error {resp.status_code} (attempt {attempt}/{
|
|
102
|
+
print(f" Server error {resp.status_code} (attempt {attempt}/{max_attempts}), retrying in {wait}s")
|
|
93
103
|
time.sleep(wait)
|
|
94
104
|
else:
|
|
95
105
|
raise
|
|
96
106
|
raise RuntimeError("unreachable")
|
|
97
107
|
|
|
98
|
-
def _get(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
108
|
+
def _get(
|
|
109
|
+
self,
|
|
110
|
+
path: str,
|
|
111
|
+
params: dict | None = None,
|
|
112
|
+
*,
|
|
113
|
+
retries: int | None = None,
|
|
114
|
+
**kwargs,
|
|
115
|
+
) -> requests.Response:
|
|
116
|
+
return self._request_with_retry("GET", path, params=params, retries=retries, **kwargs)
|
|
117
|
+
|
|
118
|
+
def _post(
|
|
119
|
+
self,
|
|
120
|
+
path: str,
|
|
121
|
+
payload: dict | None = None,
|
|
122
|
+
*,
|
|
123
|
+
retries: int | None = None,
|
|
124
|
+
**kwargs,
|
|
125
|
+
) -> requests.Response:
|
|
126
|
+
return self._request_with_retry("POST", path, json=payload, retries=retries, **kwargs)
|
|
103
127
|
|
|
104
128
|
@staticmethod
|
|
105
129
|
def _ensure_api_key(url: str, token: str | None) -> str:
|
|
@@ -119,24 +143,39 @@ class EmbyClient:
|
|
|
119
143
|
|
|
120
144
|
# -- auth ----------------------------------------------------------------
|
|
121
145
|
|
|
122
|
-
def authenticate(
|
|
146
|
+
def authenticate(
|
|
147
|
+
self,
|
|
148
|
+
username: str,
|
|
149
|
+
password: str,
|
|
150
|
+
*,
|
|
151
|
+
timeout: float | None = None,
|
|
152
|
+
retries: int | None = None,
|
|
153
|
+
) -> dict:
|
|
154
|
+
"""Authenticate by name. Returns the Emby ``User`` object from the response."""
|
|
123
155
|
data = {"Username": username, "Pw": password}
|
|
124
|
-
|
|
156
|
+
kwargs: dict = {}
|
|
157
|
+
if timeout is not None:
|
|
158
|
+
kwargs["timeout"] = timeout
|
|
159
|
+
if retries is not None:
|
|
160
|
+
kwargs["retries"] = retries
|
|
161
|
+
resp = self._post("/Users/AuthenticateByName", data, **kwargs)
|
|
125
162
|
body = resp.json()
|
|
126
163
|
try:
|
|
127
164
|
self.access_token = body["AccessToken"]
|
|
128
|
-
|
|
165
|
+
user = body["User"]
|
|
166
|
+
self.user_id = user["Id"]
|
|
129
167
|
except (KeyError, TypeError) as exc:
|
|
130
168
|
raise RuntimeError(
|
|
131
169
|
"Authentication response missing AccessToken or User.Id"
|
|
132
170
|
) from exc
|
|
171
|
+
return user
|
|
133
172
|
|
|
134
173
|
def resolve_user_id(self) -> str:
|
|
135
174
|
if self.user_id:
|
|
136
175
|
return self.user_id
|
|
137
176
|
# Prefer current user bound to the token / API key
|
|
138
177
|
try:
|
|
139
|
-
me = self.
|
|
178
|
+
me = self.get_current_user()
|
|
140
179
|
uid = me.get("Id")
|
|
141
180
|
if uid:
|
|
142
181
|
self.user_id = uid
|
|
@@ -149,11 +188,133 @@ class EmbyClient:
|
|
|
149
188
|
self.user_id = users[0]["Id"]
|
|
150
189
|
return self.user_id
|
|
151
190
|
|
|
191
|
+
# -- system --------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def get_system_info(
|
|
194
|
+
self,
|
|
195
|
+
*,
|
|
196
|
+
timeout: float | None = None,
|
|
197
|
+
retries: int | None = None,
|
|
198
|
+
) -> dict:
|
|
199
|
+
"""Authenticated server info (``GET /System/Info``).
|
|
200
|
+
|
|
201
|
+
Full details often require an admin / API-key context; non-admin
|
|
202
|
+
users may get HTTP 401/403 — prefer :meth:`get_system_info_public`
|
|
203
|
+
as a fallback.
|
|
204
|
+
"""
|
|
205
|
+
kwargs: dict = {}
|
|
206
|
+
if timeout is not None:
|
|
207
|
+
kwargs["timeout"] = timeout
|
|
208
|
+
if retries is not None:
|
|
209
|
+
kwargs["retries"] = retries
|
|
210
|
+
return self._get("/System/Info", **kwargs).json()
|
|
211
|
+
|
|
212
|
+
def get_system_info_public(
|
|
213
|
+
self,
|
|
214
|
+
*,
|
|
215
|
+
timeout: float | None = None,
|
|
216
|
+
retries: int | None = None,
|
|
217
|
+
) -> dict:
|
|
218
|
+
"""Public server info (``GET /System/Info/Public``): name, version, addresses."""
|
|
219
|
+
kwargs: dict = {}
|
|
220
|
+
if timeout is not None:
|
|
221
|
+
kwargs["timeout"] = timeout
|
|
222
|
+
if retries is not None:
|
|
223
|
+
kwargs["retries"] = retries
|
|
224
|
+
return self._get("/System/Info/Public", **kwargs).json()
|
|
225
|
+
|
|
226
|
+
def get_current_user(
|
|
227
|
+
self,
|
|
228
|
+
*,
|
|
229
|
+
timeout: float | None = None,
|
|
230
|
+
retries: int | None = None,
|
|
231
|
+
) -> dict:
|
|
232
|
+
"""Current user (``GET /Users/Me``, with fallbacks).
|
|
233
|
+
|
|
234
|
+
Some Emby builds return HTTP 500 on ``/Users/Me`` ("Guid should
|
|
235
|
+
contain…dashes"). Fall back to ``/Users/{user_id}`` when known, else
|
|
236
|
+
the first entry from ``GET /Users``.
|
|
237
|
+
"""
|
|
238
|
+
kwargs: dict = {}
|
|
239
|
+
if timeout is not None:
|
|
240
|
+
kwargs["timeout"] = timeout
|
|
241
|
+
if retries is not None:
|
|
242
|
+
kwargs["retries"] = retries
|
|
243
|
+
try:
|
|
244
|
+
return self._get("/Users/Me", **kwargs).json()
|
|
245
|
+
except requests.HTTPError:
|
|
246
|
+
pass
|
|
247
|
+
uid = self.user_id
|
|
248
|
+
if not uid:
|
|
249
|
+
users = self._get("/Users", **kwargs).json()
|
|
250
|
+
if not users:
|
|
251
|
+
raise RuntimeError("No users found on server")
|
|
252
|
+
uid = users[0]["Id"]
|
|
253
|
+
self.user_id = uid
|
|
254
|
+
return self._get(f"/Users/{uid}", **kwargs).json()
|
|
255
|
+
|
|
256
|
+
def probe_session(
|
|
257
|
+
self,
|
|
258
|
+
*,
|
|
259
|
+
username: str | None = None,
|
|
260
|
+
password: str = "",
|
|
261
|
+
) -> tuple[dict, dict]:
|
|
262
|
+
"""One-shot auth + current user + system info (no retries).
|
|
263
|
+
|
|
264
|
+
Uses the ``User`` from ``AuthenticateByName`` when logging in by name
|
|
265
|
+
(avoids broken ``/Users/Me`` on some servers). Tries ``/System/Info``
|
|
266
|
+
then ``/System/Info/Public``.
|
|
267
|
+
"""
|
|
268
|
+
kwargs = {"timeout": INFO_TIMEOUT, "retries": INFO_RETRIES}
|
|
269
|
+
user: dict | None = None
|
|
270
|
+
if username is not None:
|
|
271
|
+
user = self.authenticate(username, password, **kwargs)
|
|
272
|
+
if user is None:
|
|
273
|
+
user = self.get_current_user(**kwargs)
|
|
274
|
+
elif not user.get("Id") and self.user_id:
|
|
275
|
+
user = self.get_current_user(**kwargs)
|
|
276
|
+
try:
|
|
277
|
+
info = self.get_system_info(**kwargs)
|
|
278
|
+
except requests.HTTPError:
|
|
279
|
+
try:
|
|
280
|
+
info = self.get_system_info_public(**kwargs)
|
|
281
|
+
except requests.HTTPError:
|
|
282
|
+
info = {}
|
|
283
|
+
return user, info
|
|
284
|
+
|
|
285
|
+
def get_item_counts(
|
|
286
|
+
self,
|
|
287
|
+
user_id: str | None = None,
|
|
288
|
+
*,
|
|
289
|
+
timeout: float | None = None,
|
|
290
|
+
retries: int | None = None,
|
|
291
|
+
) -> dict:
|
|
292
|
+
"""Aggregate library counts (``GET /Items/Counts``)."""
|
|
293
|
+
params: dict = {}
|
|
294
|
+
if user_id:
|
|
295
|
+
params["UserId"] = user_id
|
|
296
|
+
kwargs: dict = {}
|
|
297
|
+
if timeout is not None:
|
|
298
|
+
kwargs["timeout"] = timeout
|
|
299
|
+
if retries is not None:
|
|
300
|
+
kwargs["retries"] = retries
|
|
301
|
+
return self._get("/Items/Counts", params=params or None, **kwargs).json()
|
|
302
|
+
|
|
152
303
|
# -- browse --------------------------------------------------------------
|
|
153
304
|
|
|
154
|
-
def get_libraries(
|
|
305
|
+
def get_libraries(
|
|
306
|
+
self,
|
|
307
|
+
*,
|
|
308
|
+
timeout: float | None = None,
|
|
309
|
+
retries: int | None = None,
|
|
310
|
+
) -> list[dict]:
|
|
155
311
|
uid = self.resolve_user_id()
|
|
156
|
-
|
|
312
|
+
kwargs: dict = {}
|
|
313
|
+
if timeout is not None:
|
|
314
|
+
kwargs["timeout"] = timeout
|
|
315
|
+
if retries is not None:
|
|
316
|
+
kwargs["retries"] = retries
|
|
317
|
+
resp = self._get(f"/Users/{uid}/Views", **kwargs)
|
|
157
318
|
return resp.json().get("Items", [])
|
|
158
319
|
|
|
159
320
|
def get_items(
|
|
@@ -21,7 +21,7 @@ def cmd_batch(client: EmbyClient, args: argparse.Namespace) -> None:
|
|
|
21
21
|
throttle = float(getattr(args, "throttle", 0) or 0)
|
|
22
22
|
method = getattr(args, "method", "download")
|
|
23
23
|
dry_run = bool(getattr(args, "dry_run", False))
|
|
24
|
-
pick_best = bool(getattr(args, "pick_best_item",
|
|
24
|
+
pick_best = bool(getattr(args, "pick_best_item", False))
|
|
25
25
|
|
|
26
26
|
if dry_run:
|
|
27
27
|
print("*** DRY RUN — no files will be downloaded ***\n")
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""info command — session + server inventory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from emby_cli.client import EmbyClient
|
|
12
|
+
from emby_cli.constants import INFO_RETRIES, INFO_TIMEOUT
|
|
13
|
+
|
|
14
|
+
# Always shown (even when 0).
|
|
15
|
+
_PRIMARY_COUNTS = (
|
|
16
|
+
("movies", "MovieCount"),
|
|
17
|
+
("series", "SeriesCount"),
|
|
18
|
+
("episodes", "EpisodeCount"),
|
|
19
|
+
("songs", "SongCount"),
|
|
20
|
+
("albums", "AlbumCount"),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Shown only when > 0.
|
|
24
|
+
_EXTRA_COUNTS = (
|
|
25
|
+
("boxsets", "BoxSetCount"),
|
|
26
|
+
("books", "BookCount"),
|
|
27
|
+
("trailers", "TrailerCount"),
|
|
28
|
+
("musicvideos", "MusicVideoCount"),
|
|
29
|
+
("artists", "ArtistCount"),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _norm_host(url: str) -> str:
|
|
34
|
+
p = urlparse(url if "://" in url else f"http://{url}")
|
|
35
|
+
host = (p.hostname or "").lower()
|
|
36
|
+
port = p.port
|
|
37
|
+
if port and port not in (80, 443, 8096):
|
|
38
|
+
return f"{host}:{port}"
|
|
39
|
+
return host
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _addr_differs(configured: str, candidate: str | None) -> bool:
|
|
43
|
+
if not candidate:
|
|
44
|
+
return False
|
|
45
|
+
return _norm_host(configured) != _norm_host(candidate)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_info(args: argparse.Namespace) -> None:
|
|
49
|
+
if not args.server:
|
|
50
|
+
print("error: provide --server or set EMBY_SERVER", file=sys.stderr)
|
|
51
|
+
sys.exit(1)
|
|
52
|
+
if not args.api_key and not args.username:
|
|
53
|
+
print(
|
|
54
|
+
"error: provide --api-key / EMBY_API_KEY or --username / EMBY_USERNAME",
|
|
55
|
+
file=sys.stderr,
|
|
56
|
+
)
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
|
|
59
|
+
configured_user = args.username or None
|
|
60
|
+
client = EmbyClient(args.server, api_key=args.api_key)
|
|
61
|
+
probe_kw = {"timeout": INFO_TIMEOUT, "retries": INFO_RETRIES}
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
user, info = client.probe_session(
|
|
65
|
+
username=args.username if args.username is not None else None,
|
|
66
|
+
password=args.password if args.password is not None else "",
|
|
67
|
+
)
|
|
68
|
+
except (requests.RequestException, RuntimeError, ValueError, KeyError, TypeError) as exc:
|
|
69
|
+
user_name = configured_user or "unknown"
|
|
70
|
+
print(f"user: {user_name}")
|
|
71
|
+
print(f"url: {client.server_url}")
|
|
72
|
+
print("server: not validated (name and version unavailable)")
|
|
73
|
+
print(f"detail: {exc}", file=sys.stderr)
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
user_name = user.get("Name") or configured_user or user.get("Id") or "unknown"
|
|
77
|
+
user_id = user.get("Id")
|
|
78
|
+
if user_id:
|
|
79
|
+
client.user_id = user_id
|
|
80
|
+
|
|
81
|
+
print(f"user: {user_name}")
|
|
82
|
+
print(f"url: {client.server_url}")
|
|
83
|
+
print(f"server: {info.get('ServerName') or info.get('Id') or 'unknown'}")
|
|
84
|
+
print(f"version: {info.get('Version') or 'unknown'}")
|
|
85
|
+
|
|
86
|
+
os_name = info.get("OperatingSystemDisplayName") or info.get("OperatingSystem")
|
|
87
|
+
if os_name:
|
|
88
|
+
print(f"os: {os_name}")
|
|
89
|
+
server_id = info.get("Id")
|
|
90
|
+
if server_id:
|
|
91
|
+
print(f"id: {server_id}")
|
|
92
|
+
if info.get("HasUpdateAvailable"):
|
|
93
|
+
print("update: available")
|
|
94
|
+
if _addr_differs(client.server_url, info.get("LocalAddress")):
|
|
95
|
+
print(f"local: {info['LocalAddress']}")
|
|
96
|
+
if _addr_differs(client.server_url, info.get("WanAddress")):
|
|
97
|
+
print(f"wan: {info['WanAddress']}")
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
libraries = client.get_libraries(**probe_kw)
|
|
101
|
+
except (requests.RequestException, RuntimeError, ValueError, KeyError, TypeError):
|
|
102
|
+
print("libraries: unavailable")
|
|
103
|
+
libraries = None
|
|
104
|
+
|
|
105
|
+
if libraries is not None:
|
|
106
|
+
names = [lib.get("Name") or lib.get("Id") or "?" for lib in libraries]
|
|
107
|
+
if names:
|
|
108
|
+
print(f"libraries: {len(names)} ({', '.join(names)})")
|
|
109
|
+
else:
|
|
110
|
+
print("libraries: 0")
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
counts = client.get_item_counts(user_id=user_id, **probe_kw)
|
|
114
|
+
except (requests.RequestException, RuntimeError, ValueError, KeyError, TypeError):
|
|
115
|
+
print("counts: unavailable")
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
for label, key in _PRIMARY_COUNTS:
|
|
119
|
+
val = counts.get(key)
|
|
120
|
+
print(f"{label}: {val if val is not None else 'unavailable'}")
|
|
121
|
+
|
|
122
|
+
for label, key in _EXTRA_COUNTS:
|
|
123
|
+
val = counts.get(key)
|
|
124
|
+
if isinstance(val, int) and val > 0:
|
|
125
|
+
print(f"{label}: {val}")
|
|
@@ -0,0 +1,50 @@
|
|
|
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.constants import DOWNLOADABLE_TYPES
|
|
10
|
+
from emby_cli.resolve import print_item_choices, print_library_choices
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def cmd_list(client: EmbyClient, args: argparse.Namespace) -> None:
|
|
14
|
+
libraries = client.get_libraries()
|
|
15
|
+
|
|
16
|
+
if args.library:
|
|
17
|
+
lib = next((l for l in libraries if l["Name"].lower() == args.library.lower()), None)
|
|
18
|
+
if not lib:
|
|
19
|
+
print(f"Library '{args.library}' not found. Available:")
|
|
20
|
+
for l in libraries:
|
|
21
|
+
print(f" - {l['Name']}")
|
|
22
|
+
sys.exit(1)
|
|
23
|
+
items = client.get_all_items(parent_id=lib["Id"])
|
|
24
|
+
targets = [i for i in items if i.get("Type") in DOWNLOADABLE_TYPES]
|
|
25
|
+
if not targets:
|
|
26
|
+
print("No results.")
|
|
27
|
+
return
|
|
28
|
+
print_item_choices(targets)
|
|
29
|
+
print(f"\nTotal: {len(targets)}")
|
|
30
|
+
print()
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
if not libraries:
|
|
34
|
+
print("No results.")
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
# Fetch all library counts first, then print one table.
|
|
38
|
+
rows: list[dict] = []
|
|
39
|
+
for lib in libraries:
|
|
40
|
+
page = client.get_items(parent_id=lib["Id"], limit=0)
|
|
41
|
+
rows.append({
|
|
42
|
+
"Id": lib.get("Id", ""),
|
|
43
|
+
"Name": lib.get("Name") or "?",
|
|
44
|
+
"Type": lib.get("CollectionType") or lib.get("Type") or "Library",
|
|
45
|
+
"ItemCount": page.get("TotalRecordCount", 0),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
print_library_choices(rows)
|
|
49
|
+
print(f"\nTotal: {len(rows)}")
|
|
50
|
+
print()
|
|
@@ -99,7 +99,7 @@ def cmd_play(client: EmbyClient, args: argparse.Namespace) -> None:
|
|
|
99
99
|
print_error(f"fetching item {item_id}: {exc}")
|
|
100
100
|
sys.exit(1)
|
|
101
101
|
else:
|
|
102
|
-
pick_best = bool(getattr(args, "pick_best_item",
|
|
102
|
+
pick_best = bool(getattr(args, "pick_best_item", False))
|
|
103
103
|
item = resolve_title_item(client, query, pick_best=pick_best)
|
|
104
104
|
if item is None:
|
|
105
105
|
sys.exit(1)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""version command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from emby_cli.client import EmbyClient
|
|
11
|
+
from emby_cli.version import get_version
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def cmd_version(args: argparse.Namespace) -> None:
|
|
15
|
+
print(f"emby-cli: {get_version()}")
|
|
16
|
+
|
|
17
|
+
has_server = bool(getattr(args, "server", None))
|
|
18
|
+
has_creds = bool(getattr(args, "api_key", None) or getattr(args, "username", None))
|
|
19
|
+
if not has_server or not has_creds:
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
client = EmbyClient(args.server, api_key=args.api_key)
|
|
23
|
+
try:
|
|
24
|
+
_user, info = client.probe_session(
|
|
25
|
+
username=args.username if args.username is not None else None,
|
|
26
|
+
password=args.password if args.password is not None else "",
|
|
27
|
+
)
|
|
28
|
+
except (requests.RequestException, RuntimeError, ValueError, KeyError, TypeError) as exc:
|
|
29
|
+
print("server: not validated (name and version unavailable)")
|
|
30
|
+
print(f"detail: {exc}", file=sys.stderr)
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
name = info.get("ServerName") or info.get("Id") or "Emby"
|
|
34
|
+
ver = info.get("Version") or "unknown"
|
|
35
|
+
print(f"server: {ver} ({name})")
|
|
@@ -4,6 +4,9 @@ DEFAULT_CHUNK = 8 * 1024 * 1024 # 8 MiB per read
|
|
|
4
4
|
TICKS_PER_SECOND = 10_000_000 # Emby RunTimeTicks resolution
|
|
5
5
|
MAX_RETRIES = 5
|
|
6
6
|
RETRY_BACKOFF_BASE = 30 # seconds; doubles each retry: 30, 60, 120, 240, 480
|
|
7
|
+
# Single-shot probes (version / info): no backoff spam on unreachable servers
|
|
8
|
+
INFO_TIMEOUT = 10 # seconds
|
|
9
|
+
INFO_RETRIES = 1
|
|
7
10
|
CLIENT_NAME = "emby-cli"
|
|
8
11
|
DEVICE_NAME = "emby-cli"
|
|
9
12
|
DEFAULT_OUTPUT = "./downloads"
|
|
@@ -131,13 +131,42 @@ def print_item_choices(
|
|
|
131
131
|
)
|
|
132
132
|
|
|
133
133
|
|
|
134
|
+
def print_library_choices(libraries: list[dict]) -> None:
|
|
135
|
+
"""Print libraries: ID, Name, Type, Items (no Year/Res/Size)."""
|
|
136
|
+
if not libraries:
|
|
137
|
+
return
|
|
138
|
+
id_w = max(len("ID"), max(len(str(lib.get("Id", ""))) for lib in libraries))
|
|
139
|
+
name_w = 44
|
|
140
|
+
type_w = max(len("Type"), max(len(str(lib.get("Type") or "?")) for lib in libraries))
|
|
141
|
+
items_w = max(len("Items"), 5)
|
|
142
|
+
|
|
143
|
+
header = (
|
|
144
|
+
f"{'ID':<{id_w}} {'Name':<{name_w}} {'Type':<{type_w}} {'Items':>{items_w}}"
|
|
145
|
+
)
|
|
146
|
+
print()
|
|
147
|
+
print(header)
|
|
148
|
+
print("-" * len(header))
|
|
149
|
+
|
|
150
|
+
for lib in libraries:
|
|
151
|
+
iid = str(lib.get("Id", ""))
|
|
152
|
+
label = str(lib.get("Name") or "?")
|
|
153
|
+
if len(label) > name_w:
|
|
154
|
+
label = label[: name_w - 1] + "…"
|
|
155
|
+
itype = str(lib.get("Type") or "?")
|
|
156
|
+
count = lib.get("ItemCount")
|
|
157
|
+
count_s = str(count) if count is not None else "?"
|
|
158
|
+
print(
|
|
159
|
+
f"{iid:<{id_w}} {label:<{name_w}} {itype:<{type_w}} {count_s:>{items_w}}"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
134
163
|
def _ambiguous(
|
|
135
164
|
items: list[dict],
|
|
136
165
|
*,
|
|
137
166
|
excluded: set[str] | None = None,
|
|
138
167
|
hint: str = (
|
|
139
168
|
"Use --item-id with an ID from the list above, "
|
|
140
|
-
"or pass --pick-best-item
|
|
169
|
+
"or pass --pick-best-item to auto-select."
|
|
141
170
|
),
|
|
142
171
|
) -> None:
|
|
143
172
|
print_item_choices(items, excluded=excluded)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Tests for the info command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from emby_cli.client import EmbyClient
|
|
12
|
+
from emby_cli.commands.info import cmd_info
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_info_requires_server():
|
|
16
|
+
args = argparse.Namespace(server=None, api_key="k", username=None, password=None)
|
|
17
|
+
with pytest.raises(SystemExit) as exc:
|
|
18
|
+
cmd_info(args)
|
|
19
|
+
assert exc.value.code == 1
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_info_ok(capsys):
|
|
23
|
+
args = argparse.Namespace(
|
|
24
|
+
server="http://host:8096",
|
|
25
|
+
api_key="k",
|
|
26
|
+
username=None,
|
|
27
|
+
password=None,
|
|
28
|
+
)
|
|
29
|
+
with patch("emby_cli.commands.info.EmbyClient") as cls:
|
|
30
|
+
client = cls.return_value
|
|
31
|
+
client.server_url = "http://host:8096"
|
|
32
|
+
client.probe_session.return_value = (
|
|
33
|
+
{"Name": "sergio", "Id": "u1"},
|
|
34
|
+
{
|
|
35
|
+
"ServerName": "home",
|
|
36
|
+
"Version": "4.8.0",
|
|
37
|
+
"OperatingSystemDisplayName": "Linux",
|
|
38
|
+
"Id": "srv-id",
|
|
39
|
+
"LocalAddress": "http://192.168.1.1:8096",
|
|
40
|
+
"WanAddress": "http://example.com:8096",
|
|
41
|
+
"HasUpdateAvailable": False,
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
client.get_libraries.return_value = [
|
|
45
|
+
{"Name": "Movies", "Id": "1"},
|
|
46
|
+
{"Name": "TV", "Id": "2"},
|
|
47
|
+
]
|
|
48
|
+
client.get_item_counts.return_value = {
|
|
49
|
+
"MovieCount": 10,
|
|
50
|
+
"SeriesCount": 2,
|
|
51
|
+
"EpisodeCount": 50,
|
|
52
|
+
"SongCount": 0,
|
|
53
|
+
"AlbumCount": 0,
|
|
54
|
+
"BoxSetCount": 3,
|
|
55
|
+
"BookCount": 0,
|
|
56
|
+
}
|
|
57
|
+
cmd_info(args)
|
|
58
|
+
|
|
59
|
+
out = capsys.readouterr().out
|
|
60
|
+
assert "user: sergio" in out
|
|
61
|
+
assert "url: http://host:8096" in out
|
|
62
|
+
assert "server: home" in out
|
|
63
|
+
assert "version: 4.8.0" in out
|
|
64
|
+
assert "os: Linux" in out
|
|
65
|
+
assert "id: srv-id" in out
|
|
66
|
+
assert "local: http://192.168.1.1:8096" in out
|
|
67
|
+
assert "wan: http://example.com:8096" in out
|
|
68
|
+
assert "libraries: 2 (Movies, TV)" in out
|
|
69
|
+
assert "movies: 10" in out
|
|
70
|
+
assert "series: 2" in out
|
|
71
|
+
assert "episodes: 50" in out
|
|
72
|
+
assert "songs: 0" in out
|
|
73
|
+
assert "albums: 0" in out
|
|
74
|
+
assert "boxsets: 3" in out
|
|
75
|
+
assert "books:" not in out
|
|
76
|
+
assert client.get_item_counts.call_args.kwargs["user_id"] == "u1"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_info_unreachable_soft(capsys):
|
|
80
|
+
args = argparse.Namespace(
|
|
81
|
+
server="http://host:8096",
|
|
82
|
+
api_key=None,
|
|
83
|
+
username="u",
|
|
84
|
+
password="bad",
|
|
85
|
+
)
|
|
86
|
+
with patch("emby_cli.commands.info.EmbyClient") as cls:
|
|
87
|
+
client = cls.return_value
|
|
88
|
+
client.server_url = "http://host:8096"
|
|
89
|
+
client.probe_session.side_effect = requests.Timeout("timed out")
|
|
90
|
+
cmd_info(args)
|
|
91
|
+
out = capsys.readouterr().out
|
|
92
|
+
assert "user: u" in out
|
|
93
|
+
assert "url: http://host:8096" in out
|
|
94
|
+
assert "server: not validated (name and version unavailable)" in out
|
|
95
|
+
assert "movies:" not in out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_get_item_counts_endpoint():
|
|
99
|
+
client = EmbyClient("http://host:8096", api_key="k")
|
|
100
|
+
mock_resp = MagicMock()
|
|
101
|
+
mock_resp.json.return_value = {"MovieCount": 1}
|
|
102
|
+
with patch.object(client, "_get", return_value=mock_resp) as get:
|
|
103
|
+
assert client.get_item_counts(user_id="uid")["MovieCount"] == 1
|
|
104
|
+
assert get.call_args.args[0] == "/Items/Counts"
|
|
105
|
+
assert get.call_args.kwargs["params"]["UserId"] == "uid"
|
|
@@ -10,7 +10,9 @@ from emby_cli.cli import build_parser
|
|
|
10
10
|
def test_build_parser_subcommands():
|
|
11
11
|
parser = build_parser()
|
|
12
12
|
subs = parser._subparsers._group_actions[0].choices
|
|
13
|
-
assert set(subs) == {
|
|
13
|
+
assert set(subs) == {
|
|
14
|
+
"list", "download", "search", "play", "sync", "batch", "version", "info",
|
|
15
|
+
}
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
def test_parser_requires_command():
|
|
@@ -30,18 +32,18 @@ def test_batch_has_pick_best_item():
|
|
|
30
32
|
parser = build_parser()
|
|
31
33
|
args = parser.parse_args([
|
|
32
34
|
"--server", "http://x", "--api-key", "k",
|
|
33
|
-
"batch", "-F", "titles.txt", "--pick-best-item",
|
|
35
|
+
"batch", "-F", "titles.txt", "--pick-best-item",
|
|
34
36
|
])
|
|
35
|
-
assert args.pick_best_item
|
|
37
|
+
assert args.pick_best_item is True
|
|
36
38
|
|
|
37
39
|
|
|
38
|
-
def
|
|
40
|
+
def test_batch_pick_best_default_false():
|
|
39
41
|
parser = build_parser()
|
|
40
42
|
args = parser.parse_args([
|
|
41
43
|
"--server", "http://x", "--api-key", "k",
|
|
42
44
|
"batch", "-F", "titles.txt",
|
|
43
45
|
])
|
|
44
|
-
assert args.pick_best_item
|
|
46
|
+
assert args.pick_best_item is False
|
|
45
47
|
|
|
46
48
|
|
|
47
49
|
def test_batch_requires_file():
|
|
@@ -65,3 +67,14 @@ def test_search_count_flag():
|
|
|
65
67
|
"search", "matrix", "--count", "50",
|
|
66
68
|
])
|
|
67
69
|
assert args.count == 50
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_version_subcommand_parses_without_server(monkeypatch):
|
|
73
|
+
monkeypatch.delenv("EMBY_SERVER", raising=False)
|
|
74
|
+
monkeypatch.delenv("EMBY_API_KEY", raising=False)
|
|
75
|
+
monkeypatch.delenv("EMBY_USERNAME", raising=False)
|
|
76
|
+
monkeypatch.delenv("EMBY_PASSWORD", raising=False)
|
|
77
|
+
parser = build_parser()
|
|
78
|
+
args = parser.parse_args(["version"])
|
|
79
|
+
assert args.command == "version"
|
|
80
|
+
assert args.server is None
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Tests for item/library table output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from emby_cli.resolve import print_item_choices, print_library_choices
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_print_item_choices_media(capsys):
|
|
9
|
+
print_item_choices([
|
|
10
|
+
{
|
|
11
|
+
"Id": "abc",
|
|
12
|
+
"Name": "Film",
|
|
13
|
+
"Type": "Movie",
|
|
14
|
+
"ProductionYear": 2020,
|
|
15
|
+
"Width": 1920,
|
|
16
|
+
"Size": 1024,
|
|
17
|
+
},
|
|
18
|
+
])
|
|
19
|
+
out = capsys.readouterr().out
|
|
20
|
+
assert "ID" in out and "Name" in out and "Year" in out and "Size" in out
|
|
21
|
+
assert "abc" in out
|
|
22
|
+
assert "Film" in out
|
|
23
|
+
assert "Movie" in out
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_print_library_choices(capsys):
|
|
27
|
+
print_library_choices([
|
|
28
|
+
{
|
|
29
|
+
"Id": "lib1",
|
|
30
|
+
"Name": "Movies",
|
|
31
|
+
"Type": "movies",
|
|
32
|
+
"ItemCount": 42,
|
|
33
|
+
},
|
|
34
|
+
])
|
|
35
|
+
out = capsys.readouterr().out
|
|
36
|
+
assert "Items" in out
|
|
37
|
+
assert "Year" not in out
|
|
38
|
+
assert "Res" not in out
|
|
39
|
+
assert "Size" not in out
|
|
40
|
+
assert "lib1" in out
|
|
41
|
+
assert "Movies" in out
|
|
42
|
+
assert "42" in out
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Tests for the version command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from emby_cli.commands.version import cmd_version
|
|
11
|
+
from emby_cli.client import EmbyClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_cmd_version_client_only(capsys):
|
|
15
|
+
args = argparse.Namespace(server=None, api_key=None, username=None, password=None)
|
|
16
|
+
with patch("emby_cli.commands.version.get_version", return_value="0.2.0"):
|
|
17
|
+
cmd_version(args)
|
|
18
|
+
out = capsys.readouterr().out
|
|
19
|
+
assert out.strip() == "emby-cli: 0.2.0"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_cmd_version_with_server(capsys):
|
|
23
|
+
args = argparse.Namespace(
|
|
24
|
+
server="http://host:8096",
|
|
25
|
+
api_key="k",
|
|
26
|
+
username=None,
|
|
27
|
+
password=None,
|
|
28
|
+
)
|
|
29
|
+
with patch("emby_cli.commands.version.get_version", return_value="0.2.0"):
|
|
30
|
+
with patch("emby_cli.commands.version.EmbyClient") as cls:
|
|
31
|
+
client = cls.return_value
|
|
32
|
+
client.probe_session.return_value = (
|
|
33
|
+
{"Name": "u"},
|
|
34
|
+
{"Version": "4.8.10.0", "ServerName": "home"},
|
|
35
|
+
)
|
|
36
|
+
cmd_version(args)
|
|
37
|
+
out = capsys.readouterr().out
|
|
38
|
+
assert "emby-cli: 0.2.0" in out
|
|
39
|
+
assert "server: 4.8.10.0 (home)" in out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_cmd_version_unreachable(capsys):
|
|
43
|
+
args = argparse.Namespace(
|
|
44
|
+
server="http://host:8096",
|
|
45
|
+
api_key=None,
|
|
46
|
+
username="u",
|
|
47
|
+
password="bad",
|
|
48
|
+
)
|
|
49
|
+
with patch("emby_cli.commands.version.get_version", return_value="0.2.0"):
|
|
50
|
+
with patch("emby_cli.commands.version.EmbyClient") as cls:
|
|
51
|
+
client = cls.return_value
|
|
52
|
+
client.probe_session.side_effect = requests.Timeout("timed out")
|
|
53
|
+
cmd_version(args)
|
|
54
|
+
out = capsys.readouterr().out
|
|
55
|
+
assert "emby-cli: 0.2.0" in out
|
|
56
|
+
assert "server: not validated (name and version unavailable)" in out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_get_system_info_endpoint():
|
|
60
|
+
client = EmbyClient("http://host:8096", api_key="k")
|
|
61
|
+
mock_resp = MagicMock()
|
|
62
|
+
mock_resp.json.return_value = {"Version": "4.8.0.0", "ServerName": "x"}
|
|
63
|
+
with patch.object(client, "_get", return_value=mock_resp) as get:
|
|
64
|
+
assert client.get_system_info()["Version"] == "4.8.0.0"
|
|
65
|
+
assert get.call_args.args[0] == "/System/Info"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_probe_session_falls_back_to_public_system_info():
|
|
69
|
+
client = EmbyClient("http://host:8096", api_key="k")
|
|
70
|
+
client.user_id = "u1"
|
|
71
|
+
me = MagicMock()
|
|
72
|
+
me.json.return_value = {"Name": "u", "Id": "u1"}
|
|
73
|
+
public = MagicMock()
|
|
74
|
+
public.json.return_value = {"ServerName": "home", "Version": "4.8.0"}
|
|
75
|
+
|
|
76
|
+
def fake_get(path, **kwargs):
|
|
77
|
+
if path == "/Users/Me":
|
|
78
|
+
return me
|
|
79
|
+
if path == "/System/Info":
|
|
80
|
+
raise requests.HTTPError("403")
|
|
81
|
+
if path == "/System/Info/Public":
|
|
82
|
+
return public
|
|
83
|
+
raise AssertionError(path)
|
|
84
|
+
|
|
85
|
+
with patch.object(client, "_get", side_effect=fake_get):
|
|
86
|
+
user, info = client.probe_session()
|
|
87
|
+
assert user["Name"] == "u"
|
|
88
|
+
assert info["ServerName"] == "home"
|
|
89
|
+
assert info["Version"] == "4.8.0"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_probe_session_uses_auth_user_skips_users_me():
|
|
93
|
+
client = EmbyClient("http://host:8096")
|
|
94
|
+
auth_user = {"Name": "u", "Id": "abcd" * 8}
|
|
95
|
+
|
|
96
|
+
def fake_get(path, **kwargs):
|
|
97
|
+
if path == "/Users/Me":
|
|
98
|
+
raise AssertionError("/Users/Me should not be called after auth")
|
|
99
|
+
if path == "/System/Info":
|
|
100
|
+
resp = MagicMock()
|
|
101
|
+
resp.json.return_value = {"ServerName": "home", "Version": "4.8"}
|
|
102
|
+
return resp
|
|
103
|
+
raise AssertionError(path)
|
|
104
|
+
|
|
105
|
+
with patch.object(client, "authenticate", return_value=auth_user) as auth:
|
|
106
|
+
with patch.object(client, "_get", side_effect=fake_get):
|
|
107
|
+
user, info = client.probe_session(username="u", password="p")
|
|
108
|
+
auth.assert_called_once()
|
|
109
|
+
assert user is auth_user
|
|
110
|
+
assert info["ServerName"] == "home"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_get_current_user_falls_back_to_users_id():
|
|
114
|
+
client = EmbyClient("http://host:8096", api_key="k")
|
|
115
|
+
client.user_id = "uid-1"
|
|
116
|
+
by_id = MagicMock()
|
|
117
|
+
by_id.json.return_value = {"Name": "u", "Id": "uid-1"}
|
|
118
|
+
|
|
119
|
+
def fake_get(path, **kwargs):
|
|
120
|
+
if path == "/Users/Me":
|
|
121
|
+
raise requests.HTTPError("500 Guid")
|
|
122
|
+
if path == "/Users/uid-1":
|
|
123
|
+
return by_id
|
|
124
|
+
raise AssertionError(path)
|
|
125
|
+
|
|
126
|
+
with patch.object(client, "_get", side_effect=fake_get):
|
|
127
|
+
assert client.get_current_user()["Name"] == "u"
|
emby_cli-0.2.0/CHANGELOG.md
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
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,32 +0,0 @@
|
|
|
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.constants import DOWNLOADABLE_TYPES
|
|
10
|
-
from emby_cli.resolve import print_item_choices
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def cmd_list(client: EmbyClient, args: argparse.Namespace) -> None:
|
|
14
|
-
libraries = client.get_libraries()
|
|
15
|
-
|
|
16
|
-
if args.library:
|
|
17
|
-
lib = next((l for l in libraries if l["Name"].lower() == args.library.lower()), None)
|
|
18
|
-
if not lib:
|
|
19
|
-
print(f"Library '{args.library}' not found. Available:")
|
|
20
|
-
for l in libraries:
|
|
21
|
-
print(f" - {l['Name']}")
|
|
22
|
-
sys.exit(1)
|
|
23
|
-
items = client.get_all_items(parent_id=lib["Id"])
|
|
24
|
-
targets = [i for i in items if i.get("Type") in DOWNLOADABLE_TYPES]
|
|
25
|
-
print_item_choices(targets)
|
|
26
|
-
print(f"\nTotal downloadable items: {len(targets)}")
|
|
27
|
-
else:
|
|
28
|
-
print("\nLibraries on server:")
|
|
29
|
-
for lib in libraries:
|
|
30
|
-
count = client.get_items(parent_id=lib["Id"], limit=0)
|
|
31
|
-
print(f" [{lib['Id'][:8]}] {lib['Name']:<30} ({count.get('TotalRecordCount', '?')} items)")
|
|
32
|
-
print()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|