emby-cli 0.0.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
emby_cli/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """emby-cli — backup and stream media from an Emby server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+
7
+ # macOS system Python uses LibreSSL; urllib3 v2 only warns (TLS still works).
8
+ # Must run before any import that pulls in requests/urllib3 (see client.py).
9
+ warnings.filterwarnings("ignore", message="urllib3 v2 only supports OpenSSL")
10
+
11
+ from emby_cli.client import EmbyClient
12
+ from emby_cli.version import get_version
13
+
14
+ __all__ = ["EmbyClient", "get_version"]
emby_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from emby_cli.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
emby_cli/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.0.0rc1'
22
+ __version_tuple__ = version_tuple = (0, 0, 0, 'rc1')
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,7 @@
1
+ """Entity-oriented services sharing one :class:`EmbyClient` transport."""
2
+
3
+ from emby_cli.api.collections import CollectionsService
4
+ from emby_cli.api.items import ItemsService
5
+ from emby_cli.api.libraries import LibrariesService
6
+
7
+ __all__ = ["CollectionsService", "ItemsService", "LibrariesService"]
@@ -0,0 +1,110 @@
1
+ """Emby BoxSet collection operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from emby_cli.data_cache import delete_json
8
+
9
+ if TYPE_CHECKING:
10
+ from emby_cli.client import EmbyClient
11
+
12
+
13
+ COLLECTION_FIELDS = "ChildCount,ProductionYear,DisplayOrder,SortName"
14
+ COLLECTION_DETAIL_FIELDS = COLLECTION_FIELDS + ",Overview"
15
+
16
+
17
+ class CollectionsService:
18
+ """Entity service for Emby ``BoxSet`` resources."""
19
+
20
+ def __init__(self, client: EmbyClient):
21
+ self.client = client
22
+
23
+ def _catalog_key(self) -> str:
24
+ uid = self.client.resolve_user_id()
25
+ return f"v2:collections:{self.client.server_url}:{uid}"
26
+
27
+ def list(self, *, use_cache: bool = True) -> list[dict]:
28
+ """Return the complete collection catalog."""
29
+ key = self._catalog_key()
30
+ if use_cache:
31
+ cached = self.client._cache_read(key)
32
+ if isinstance(cached, list):
33
+ return cached
34
+ collections = self.client.items.list_all(
35
+ item_types="BoxSet",
36
+ fields=COLLECTION_FIELDS,
37
+ use_cache=False,
38
+ )
39
+ if use_cache:
40
+ self.client._cache_write(key, collections)
41
+ return collections
42
+
43
+ def search(self, query: str, *, use_cache: bool = True) -> list[dict]:
44
+ """Search collection names locally over the complete catalog."""
45
+ needle = query.strip().casefold()
46
+ collections = self.list(use_cache=use_cache)
47
+ if not needle:
48
+ return collections
49
+ return [
50
+ item
51
+ for item in collections
52
+ if needle in str(item.get("Name") or "").casefold()
53
+ ]
54
+
55
+ def create(
56
+ self,
57
+ name: str,
58
+ *,
59
+ item_ids: list[str] | None = None,
60
+ parent_id: str | None = None,
61
+ is_locked: bool = False,
62
+ ) -> dict:
63
+ params: dict = {
64
+ "Name": name,
65
+ "IsLocked": str(is_locked).lower(),
66
+ }
67
+ if item_ids:
68
+ params["Ids"] = ",".join(item_ids)
69
+ if parent_id:
70
+ params["ParentId"] = parent_id
71
+ result = self.client._post(
72
+ "/Collections",
73
+ params=params,
74
+ retries=1,
75
+ ).json()
76
+ self.invalidate_catalog()
77
+ return result
78
+
79
+ def add_items(self, collection_id: str, item_ids: list[str]) -> None:
80
+ self.client._post(
81
+ f"/Collections/{collection_id}/Items",
82
+ params={"Ids": ",".join(item_ids)},
83
+ )
84
+ self.invalidate(collection_id)
85
+
86
+ def remove_items(self, collection_id: str, item_ids: list[str]) -> None:
87
+ self.client._delete(
88
+ f"/Collections/{collection_id}/Items",
89
+ params={"Ids": ",".join(item_ids)},
90
+ )
91
+ self.invalidate(collection_id)
92
+
93
+ def delete(self, collection_id: str) -> None:
94
+ item = self.client.items.get(collection_id, use_cache=False)
95
+ if item.get("Type") != "BoxSet":
96
+ actual = item.get("Type") or "unknown"
97
+ raise ValueError(
98
+ f"refusing to delete item {collection_id}: expected BoxSet, got {actual}"
99
+ )
100
+ self.client.items.delete(collection_id)
101
+ self.invalidate(collection_id)
102
+
103
+ def invalidate_catalog(self) -> None:
104
+ delete_json(self._catalog_key())
105
+
106
+ def invalidate(self, collection_id: str) -> None:
107
+ self.invalidate_catalog()
108
+ self.client.items.invalidate(collection_id)
109
+ self.client.items.invalidate(collection_id, fields=COLLECTION_DETAIL_FIELDS)
110
+ self.client.items.invalidate_list(parent_id=collection_id)
emby_cli/api/items.py ADDED
@@ -0,0 +1,380 @@
1
+ """Generic Emby item operations built on the shared client transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Literal
6
+
7
+ from emby_cli.constants import ITEM_FIELDS, SEARCH_ITEM_TYPES
8
+ from emby_cli.data_cache import delete_json
9
+ from emby_cli.media_sort import sort_media_items
10
+
11
+ if TYPE_CHECKING:
12
+ from emby_cli.client import EmbyClient
13
+
14
+ ListingDefault = Literal["catalog", "parent"]
15
+
16
+
17
+ class ItemsService:
18
+ """Read and mutate generic ``/Items`` resources."""
19
+
20
+ def __init__(self, client: EmbyClient):
21
+ self.client = client
22
+
23
+ def _key(self, item_id: str, fields: str | None) -> str:
24
+ uid = self.client.resolve_user_id()
25
+ return (
26
+ f"v2:item:{self.client.server_url}:{uid}:"
27
+ f"{item_id}:{fields or 'all'}"
28
+ )
29
+
30
+ def _listing_key(
31
+ self,
32
+ *,
33
+ parent_id: str | None,
34
+ query: str | None,
35
+ item_types: str,
36
+ year: int | None,
37
+ limit: int | None,
38
+ emby_sort: str,
39
+ sort_order: str,
40
+ fields: str | None,
41
+ recursive: bool,
42
+ client_sort: str | None,
43
+ client_desc: bool,
44
+ ) -> str:
45
+ uid = self.client.resolve_user_id()
46
+ query_part = query if query is not None else ""
47
+ year_part = str(year) if year is not None else ""
48
+ limit_part = str(limit) if limit is not None else "all"
49
+ client_part = f"{client_sort}:{'desc' if client_desc else 'asc'}" if client_sort else ""
50
+ return (
51
+ f"v2:item-list:{self.client.server_url}:{uid}:"
52
+ f"{parent_id or ''}:{query_part}:{item_types}:{year_part}:"
53
+ f"{limit_part}:{emby_sort}:{sort_order}:{fields or ITEM_FIELDS}:"
54
+ f"{recursive}:{client_part}"
55
+ )
56
+
57
+ def _list_key(
58
+ self,
59
+ *,
60
+ parent_id: str | None,
61
+ item_types: str | None,
62
+ fields: str | None,
63
+ recursive: bool,
64
+ sort_by: str,
65
+ sort_order: str,
66
+ ) -> str:
67
+ return self._listing_key(
68
+ parent_id=parent_id,
69
+ query=None,
70
+ item_types=item_types or SEARCH_ITEM_TYPES,
71
+ year=None,
72
+ limit=None,
73
+ emby_sort=sort_by,
74
+ sort_order=sort_order,
75
+ fields=fields,
76
+ recursive=recursive,
77
+ client_sort=None,
78
+ client_desc=False,
79
+ )
80
+
81
+ def _search_key(
82
+ self,
83
+ query: str,
84
+ item_types: str,
85
+ *,
86
+ year: int | None = None,
87
+ limit: int | None = None,
88
+ sort_by: str | None = None,
89
+ desc: bool = False,
90
+ ) -> str:
91
+ emby_sort, sort_order, client_sort = self._resolve_list_sort(
92
+ sort_by,
93
+ desc=desc,
94
+ when_unsorted="catalog",
95
+ )
96
+ return self._listing_key(
97
+ parent_id=None,
98
+ query=query,
99
+ item_types=item_types,
100
+ year=year,
101
+ limit=limit,
102
+ emby_sort=emby_sort,
103
+ sort_order=sort_order,
104
+ fields=ITEM_FIELDS,
105
+ recursive=True,
106
+ client_sort=client_sort,
107
+ client_desc=desc,
108
+ )
109
+
110
+ @staticmethod
111
+ def _emby_sort_fields(primary: str) -> str:
112
+ """Append ``SortName`` as stable tie-breaker unless already sorting by name."""
113
+ if primary == "SortName" or primary.endswith(",SortName"):
114
+ return primary
115
+ return f"{primary},SortName"
116
+
117
+ @staticmethod
118
+ def _emby_sort(
119
+ sort_by: str | None,
120
+ *,
121
+ desc: bool,
122
+ ) -> tuple[str, str]:
123
+ if sort_by == "id":
124
+ return "SortName", "Ascending"
125
+ mapping = {
126
+ "name": "SortName",
127
+ "year": "ProductionYear",
128
+ "release-date": "PremiereDate",
129
+ "added-date": "DateCreated",
130
+ "resolution": "Resolution",
131
+ "size": "Size",
132
+ }
133
+ emby_sort = ItemsService._emby_sort_fields(mapping.get(sort_by or "", "DateCreated"))
134
+ order = "Descending" if desc else "Ascending"
135
+ if sort_by is None and not desc:
136
+ order = "Descending"
137
+ return emby_sort, order
138
+
139
+ @staticmethod
140
+ def _resolve_list_sort(
141
+ sort_by: str | None,
142
+ *,
143
+ desc: bool,
144
+ when_unsorted: ListingDefault,
145
+ ) -> tuple[str, str, str | None]:
146
+ """Return Emby sort, order, and optional client-side re-sort key."""
147
+ if sort_by == "id":
148
+ emby_sort, sort_order = ItemsService._emby_sort("id", desc=desc)
149
+ return emby_sort, sort_order, "id"
150
+ if sort_by:
151
+ emby_sort, sort_order = ItemsService._emby_sort(sort_by, desc=desc)
152
+ return emby_sort, sort_order, None
153
+ if when_unsorted == "parent":
154
+ return "SortName", "Ascending", None
155
+ emby_sort, sort_order = ItemsService._emby_sort(None, desc=desc)
156
+ return emby_sort, sort_order, None
157
+
158
+ def _list_items_raw(
159
+ self,
160
+ *,
161
+ parent_id: str | None = None,
162
+ query: str | None = None,
163
+ item_types: str | None = None,
164
+ year: int | None = None,
165
+ limit: int | None = None,
166
+ emby_sort: str,
167
+ sort_order: str,
168
+ fields: str | None = None,
169
+ recursive: bool = True,
170
+ client_sort: str | None = None,
171
+ client_desc: bool = False,
172
+ use_cache: bool = True,
173
+ ) -> tuple[list[dict], int]:
174
+ """Fetch ``/Users/{uid}/Items`` with explicit Emby sort parameters."""
175
+ types = item_types or SEARCH_ITEM_TYPES
176
+ fetch_limit = None if client_sort else limit
177
+ key = self._listing_key(
178
+ parent_id=parent_id,
179
+ query=query,
180
+ item_types=types,
181
+ year=year,
182
+ limit=limit,
183
+ emby_sort=emby_sort,
184
+ sort_order=sort_order,
185
+ fields=fields,
186
+ recursive=recursive,
187
+ client_sort=client_sort,
188
+ client_desc=client_desc,
189
+ )
190
+ if use_cache:
191
+ cached = self.client._cache_read(key)
192
+ if isinstance(cached, dict):
193
+ items = cached.get("items")
194
+ total = cached.get("total")
195
+ if isinstance(items, list) and isinstance(total, int):
196
+ return items, total
197
+
198
+ uid = self.client.resolve_user_id()
199
+ params: dict = {
200
+ "Recursive": str(recursive).lower(),
201
+ "Fields": fields or ITEM_FIELDS,
202
+ "SortBy": emby_sort,
203
+ "SortOrder": sort_order,
204
+ }
205
+ if query is not None:
206
+ params["SearchTerm"] = query
207
+ if parent_id:
208
+ params["ParentId"] = parent_id
209
+ params["IncludeItemTypes"] = types
210
+ if year is not None:
211
+ params["Years"] = str(year)
212
+
213
+ items, total = self.client._paginate(
214
+ f"/Users/{uid}/Items",
215
+ params,
216
+ limit=fetch_limit,
217
+ )
218
+ if client_sort:
219
+ items = sort_media_items(items, client_sort, desc=client_desc)
220
+ total = len(items)
221
+ if limit is not None:
222
+ items = items[:limit]
223
+
224
+ payload = {"items": items, "total": int(total)}
225
+ if use_cache:
226
+ self.client._cache_write(key, payload)
227
+ return items, int(total)
228
+
229
+ def list_items(
230
+ self,
231
+ *,
232
+ query: str = "",
233
+ parent_id: str | None = None,
234
+ item_types: str | None = None,
235
+ year: int | None = None,
236
+ limit: int | None = None,
237
+ sort_by: str | None = None,
238
+ desc: bool = False,
239
+ when_unsorted: ListingDefault = "catalog",
240
+ fields: str | None = None,
241
+ recursive: bool = True,
242
+ use_cache: bool = True,
243
+ ) -> tuple[list[dict], int]:
244
+ """List playable media items with CLI sort keys and optional scope filters."""
245
+ emby_sort, sort_order, client_sort = self._resolve_list_sort(
246
+ sort_by,
247
+ desc=desc,
248
+ when_unsorted=when_unsorted,
249
+ )
250
+ search_term = None if parent_id is not None else query
251
+ return self._list_items_raw(
252
+ parent_id=parent_id,
253
+ query=search_term,
254
+ item_types=item_types,
255
+ year=year,
256
+ limit=limit,
257
+ emby_sort=emby_sort,
258
+ sort_order=sort_order,
259
+ fields=fields,
260
+ recursive=recursive,
261
+ client_sort=client_sort,
262
+ client_desc=desc,
263
+ use_cache=use_cache,
264
+ )
265
+
266
+ def list_all(
267
+ self,
268
+ *,
269
+ parent_id: str | None = None,
270
+ item_types: str | None = None,
271
+ fields: str | None = None,
272
+ recursive: bool = True,
273
+ sort_by: str = "SortName",
274
+ sort_order: str = "Ascending",
275
+ use_cache: bool = True,
276
+ ) -> list[dict]:
277
+ """Return every matching item with explicit Emby ``SortBy`` field names."""
278
+ items, _total = self._list_items_raw(
279
+ parent_id=parent_id,
280
+ query=None,
281
+ item_types=item_types,
282
+ emby_sort=sort_by,
283
+ sort_order=sort_order,
284
+ fields=fields,
285
+ recursive=recursive,
286
+ use_cache=use_cache,
287
+ )
288
+ return items
289
+
290
+ def search(
291
+ self,
292
+ query: str = "",
293
+ *,
294
+ item_types: str | None = None,
295
+ year: int | None = None,
296
+ limit: int | None = None,
297
+ sort_by: str | None = None,
298
+ desc: bool = False,
299
+ use_cache: bool = True,
300
+ ) -> tuple[list[dict], int]:
301
+ """Search playable media items via Emby ``SearchTerm`` and filters."""
302
+ return self.list_items(
303
+ query=query,
304
+ item_types=item_types,
305
+ year=year,
306
+ limit=limit,
307
+ sort_by=sort_by,
308
+ desc=desc,
309
+ when_unsorted="catalog",
310
+ use_cache=use_cache,
311
+ )
312
+
313
+ def get(
314
+ self,
315
+ item_id: str,
316
+ *,
317
+ fields: str | None = None,
318
+ use_cache: bool = True,
319
+ ) -> dict:
320
+ """Get one item for the current user."""
321
+ uid = self.client.resolve_user_id()
322
+ key = self._key(item_id, fields)
323
+ if use_cache:
324
+ cached = self.client._cache_read(key)
325
+ if isinstance(cached, dict):
326
+ return cached
327
+ params = {"Fields": fields} if fields else None
328
+ data = self.client._get(
329
+ f"/Users/{uid}/Items/{item_id}",
330
+ params=params,
331
+ ).json()
332
+ if use_cache:
333
+ self.client._cache_write(key, data)
334
+ return data
335
+
336
+ def update(self, item_id: str, item: dict) -> None:
337
+ """Update an item with the complete object returned by Emby."""
338
+ self.client._post(f"/Items/{item_id}", item)
339
+ self.invalidate(item_id)
340
+
341
+ def merge_and_update(
342
+ self,
343
+ item_id: str,
344
+ updates: dict[str, object],
345
+ *,
346
+ fields: str | None = None,
347
+ ) -> dict:
348
+ """GET one item uncached, merge *updates*, POST the full object back."""
349
+ item = self.get(item_id, fields=fields, use_cache=False)
350
+ for key, value in updates.items():
351
+ item[key] = value
352
+ self.update(item_id, item)
353
+ return item
354
+
355
+ def delete(self, item_id: str) -> None:
356
+ """Delete one item using Emby's generic destructive endpoint."""
357
+ self.client._delete(f"/Items/{item_id}")
358
+ self.invalidate(item_id)
359
+
360
+ def invalidate(self, item_id: str, *, fields: str | None = None) -> None:
361
+ delete_json(self._key(item_id, fields))
362
+
363
+ def invalidate_list(
364
+ self,
365
+ *,
366
+ parent_id: str | None = None,
367
+ item_types: str | None = None,
368
+ fields: str | None = None,
369
+ recursive: bool = True,
370
+ sort_by: str = "SortName",
371
+ sort_order: str = "Ascending",
372
+ ) -> None:
373
+ delete_json(self._list_key(
374
+ parent_id=parent_id,
375
+ item_types=item_types,
376
+ fields=fields,
377
+ recursive=recursive,
378
+ sort_by=sort_by,
379
+ sort_order=sort_order,
380
+ ))
@@ -0,0 +1,49 @@
1
+ """Emby library view operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from emby_cli.data_cache import delete_json
8
+
9
+ if TYPE_CHECKING:
10
+ from emby_cli.client import EmbyClient
11
+
12
+
13
+ class LibrariesService:
14
+ """Entity service for Emby library views (``/Users/{uid}/Views``)."""
15
+
16
+ def __init__(self, client: EmbyClient):
17
+ self.client = client
18
+
19
+ def _catalog_key(self) -> str:
20
+ uid = self.client.resolve_user_id()
21
+ return f"v2:libraries:{self.client.server_url}:{uid}"
22
+
23
+ def list(self, *, use_cache: bool = True) -> list[dict]:
24
+ """Return every library view for the current user."""
25
+ key = self._catalog_key()
26
+ if use_cache:
27
+ cached = self.client._cache_read(key)
28
+ if isinstance(cached, list):
29
+ return cached
30
+ uid = self.client.resolve_user_id()
31
+ libraries = self.client._get(f"/Users/{uid}/Views").json().get("Items", [])
32
+ if use_cache:
33
+ self.client._cache_write(key, libraries)
34
+ return libraries
35
+
36
+ def search(self, query: str, *, use_cache: bool = True) -> list[dict]:
37
+ """Search library names locally over the complete catalog."""
38
+ needle = query.strip().casefold()
39
+ libraries = self.list(use_cache=use_cache)
40
+ if not needle:
41
+ return libraries
42
+ return [
43
+ lib
44
+ for lib in libraries
45
+ if needle in str(lib.get("Name") or "").casefold()
46
+ ]
47
+
48
+ def invalidate_catalog(self) -> None:
49
+ delete_json(self._catalog_key())