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.
Files changed (39) hide show
  1. emby_cli-0.2.2/CHANGELOG.md +22 -0
  2. {emby_cli-0.2.0 → emby_cli-0.2.2}/PKG-INFO +8 -4
  3. {emby_cli-0.2.0 → emby_cli-0.2.2}/README.md +7 -3
  4. {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/_version.py +2 -2
  5. {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/cli.py +23 -6
  6. {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/client.py +178 -17
  7. {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/commands/batch.py +1 -1
  8. emby_cli-0.2.2/src/emby_cli/commands/info.py +125 -0
  9. emby_cli-0.2.2/src/emby_cli/commands/list.py +50 -0
  10. {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/commands/play.py +1 -1
  11. emby_cli-0.2.2/src/emby_cli/commands/version.py +35 -0
  12. {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/constants.py +3 -0
  13. {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/resolve.py +30 -1
  14. emby_cli-0.2.2/tests/test_info.py +105 -0
  15. {emby_cli-0.2.0 → emby_cli-0.2.2}/tests/test_parser.py +18 -5
  16. emby_cli-0.2.2/tests/test_resolve_table.py +42 -0
  17. emby_cli-0.2.2/tests/test_version.py +127 -0
  18. emby_cli-0.2.0/CHANGELOG.md +0 -11
  19. emby_cli-0.2.0/src/emby_cli/commands/list.py +0 -32
  20. {emby_cli-0.2.0 → emby_cli-0.2.2}/.env.example +0 -0
  21. {emby_cli-0.2.0 → emby_cli-0.2.2}/.github/workflows/ci.yml +0 -0
  22. {emby_cli-0.2.0 → emby_cli-0.2.2}/.github/workflows/publish.yml +0 -0
  23. {emby_cli-0.2.0 → emby_cli-0.2.2}/.gitignore +0 -0
  24. {emby_cli-0.2.0 → emby_cli-0.2.2}/LICENSE +0 -0
  25. {emby_cli-0.2.0 → emby_cli-0.2.2}/pyproject.toml +0 -0
  26. {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/__init__.py +0 -0
  27. {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/__main__.py +0 -0
  28. {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/commands/__init__.py +0 -0
  29. {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/commands/download.py +0 -0
  30. {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/commands/search.py +0 -0
  31. {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/commands/sync.py +0 -0
  32. {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/download_ops.py +0 -0
  33. {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/output.py +0 -0
  34. {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/util.py +0 -0
  35. {emby_cli-0.2.0 → emby_cli-0.2.2}/src/emby_cli/version.py +0 -0
  36. {emby_cli-0.2.0 → emby_cli-0.2.2}/tests/test_client.py +0 -0
  37. {emby_cli-0.2.0 → emby_cli-0.2.2}/tests/test_output.py +0 -0
  38. {emby_cli-0.2.0 → emby_cli-0.2.2}/tests/test_resolve.py +0 -0
  39. {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.0
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 1
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 1
83
+ emby-cli play "Show S01E01" --pick-best-item
82
84
  ```
83
85
 
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).
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 1
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 1
49
+ emby-cli play "Show S01E01" --pick-best-item
48
50
  ```
49
51
 
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).
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.0'
22
- __version_tuple__ = version_tuple = (0, 2, 0)
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", type=int, choices=[0, 1], default=0,
82
- help="On ambiguous search results: 0=list and fail (default), "
83
- "1=auto-select best ≤1080p")
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", 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)")
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(self, method: str, path: str, **kwargs) -> requests.Response:
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, MAX_RETRIES + 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 == MAX_RETRIES:
94
+ if attempt == max_attempts:
85
95
  raise
86
96
  wait = RETRY_BACKOFF_BASE * (2 ** (attempt - 1))
87
- print(f" Connection error (attempt {attempt}/{MAX_RETRIES}), retrying in {wait}s: {exc}")
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 < MAX_RETRIES:
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}/{MAX_RETRIES}), retrying in {wait}s")
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(self, path: str, params: dict | None = None, **kwargs) -> requests.Response:
99
- return self._request_with_retry("GET", path, params=params, **kwargs)
100
-
101
- def _post(self, path: str, payload: dict | None = None) -> requests.Response:
102
- return self._request_with_retry("POST", path, json=payload)
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(self, username: str, password: str) -> None:
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
- resp = self._post("/Users/AuthenticateByName", data)
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
- self.user_id = body["User"]["Id"]
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._get("/Users/Me").json()
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(self) -> list[dict]:
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
- resp = self._get(f"/Users/{uid}/Views")
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", 0))
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", 0))
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=1 to auto-select."
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) == {"list", "download", "search", "play", "sync", "batch"}
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", "1",
35
+ "batch", "-F", "titles.txt", "--pick-best-item",
34
36
  ])
35
- assert args.pick_best_item == 1
37
+ assert args.pick_best_item is True
36
38
 
37
39
 
38
- def test_batch_pick_best_default_zero():
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 == 0
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"
@@ -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