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 +14 -0
- emby_cli/__main__.py +4 -0
- emby_cli/_version.py +24 -0
- emby_cli/api/__init__.py +7 -0
- emby_cli/api/collections.py +110 -0
- emby_cli/api/items.py +380 -0
- emby_cli/api/libraries.py +49 -0
- emby_cli/auth_cache.py +389 -0
- emby_cli/cli.py +703 -0
- emby_cli/client.py +1059 -0
- emby_cli/collection_ops.py +368 -0
- emby_cli/commands/__init__.py +1 -0
- emby_cli/commands/collection.py +495 -0
- emby_cli/commands/config.py +130 -0
- emby_cli/commands/help.py +27 -0
- emby_cli/commands/info.py +131 -0
- emby_cli/commands/item.py +361 -0
- emby_cli/commands/library.py +259 -0
- emby_cli/commands/login.py +29 -0
- emby_cli/commands/logout.py +73 -0
- emby_cli/commands/version.py +38 -0
- emby_cli/constants.py +30 -0
- emby_cli/credentials.py +115 -0
- emby_cli/data_cache.py +72 -0
- emby_cli/detail_output.py +167 -0
- emby_cli/download_ops.py +75 -0
- emby_cli/item_ops.py +1013 -0
- emby_cli/library_ops.py +183 -0
- emby_cli/media_sort.py +92 -0
- emby_cli/output.py +80 -0
- emby_cli/resolve.py +375 -0
- emby_cli/types.py +125 -0
- emby_cli/util.py +170 -0
- emby_cli/version.py +17 -0
- emby_cli-0.0.0rc1.dist-info/METADATA +287 -0
- emby_cli-0.0.0rc1.dist-info/RECORD +39 -0
- emby_cli-0.0.0rc1.dist-info/WHEEL +4 -0
- emby_cli-0.0.0rc1.dist-info/entry_points.txt +2 -0
- emby_cli-0.0.0rc1.dist-info/licenses/LICENSE +20 -0
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
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
|
emby_cli/api/__init__.py
ADDED
|
@@ -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())
|